plume 0.1.0 → 0.2.0

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +280 -10
  3. data/lib/plume/analysis/resolver.rb +574 -0
  4. data/lib/plume/analysis.rb +135 -0
  5. data/lib/plume/ast/alter_table_statement.rb +29 -0
  6. data/lib/plume/ast/analyze_statement.rb +15 -0
  7. data/lib/plume/ast/assignment.rb +18 -0
  8. data/lib/plume/ast/attach_statement.rb +17 -0
  9. data/lib/plume/ast/begin_statement.rb +17 -0
  10. data/lib/plume/ast/binary_expression.rb +71 -0
  11. data/lib/plume/ast/case_condition.rb +29 -0
  12. data/lib/plume/ast/case_expression.rb +22 -0
  13. data/lib/plume/ast/cast_expression.rb +16 -0
  14. data/lib/plume/ast/check_column_constraint.rb +20 -0
  15. data/lib/plume/ast/check_table_constraint.rb +19 -0
  16. data/lib/plume/ast/collate_column_constraint.rb +19 -0
  17. data/lib/plume/ast/collation_expression.rb +26 -0
  18. data/lib/plume/ast/column_constraint.rb +7 -0
  19. data/lib/plume/ast/column_definition.rb +16 -0
  20. data/lib/plume/ast/column_name.rb +22 -0
  21. data/lib/plume/ast/column_type.rb +25 -0
  22. data/lib/plume/ast/commit_statement.rb +13 -0
  23. data/lib/plume/ast/common_table_expression.rb +31 -0
  24. data/lib/plume/ast/compound_select.rb +30 -0
  25. data/lib/plume/ast/conflict_clause.rb +22 -0
  26. data/lib/plume/ast/conflict_resolution.rb +20 -0
  27. data/lib/plume/ast/create_index_statement.rb +29 -0
  28. data/lib/plume/ast/create_table_statement.rb +47 -0
  29. data/lib/plume/ast/create_trigger_statement.rb +43 -0
  30. data/lib/plume/ast/create_view_statement.rb +30 -0
  31. data/lib/plume/ast/create_virtual_table_statement.rb +28 -0
  32. data/lib/plume/ast/default_column_constraint.rb +29 -0
  33. data/lib/plume/ast/delete_statement.rb +28 -0
  34. data/lib/plume/ast/detach_statement.rb +13 -0
  35. data/lib/plume/ast/drop_statement.rb +21 -0
  36. data/lib/plume/ast/empty_function_argument.rb +6 -0
  37. data/lib/plume/ast/exists_expression.rb +15 -0
  38. data/lib/plume/ast/explain_statement.rb +15 -0
  39. data/lib/plume/ast/expression.rb +30 -0
  40. data/lib/plume/ast/foreign_key_action.rb +26 -0
  41. data/lib/plume/ast/foreign_key_clause.rb +50 -0
  42. data/lib/plume/ast/foreign_key_column_constraint.rb +16 -0
  43. data/lib/plume/ast/foreign_key_table_constraint.rb +20 -0
  44. data/lib/plume/ast/frame_boundary.rb +35 -0
  45. data/lib/plume/ast/frame_spec.rb +34 -0
  46. data/lib/plume/ast/function_arguments.rb +17 -0
  47. data/lib/plume/ast/function_reference.rb +29 -0
  48. data/lib/plume/ast/generated_as_column_constraint.rb +24 -0
  49. data/lib/plume/ast/indexed_column.rb +21 -0
  50. data/lib/plume/ast/insert_statement.rb +35 -0
  51. data/lib/plume/ast/join.rb +12 -0
  52. data/lib/plume/ast/join_clause.rb +15 -0
  53. data/lib/plume/ast/join_on_constraint.rb +12 -0
  54. data/lib/plume/ast/join_operator.rb +31 -0
  55. data/lib/plume/ast/join_using_constraint.rb +16 -0
  56. data/lib/plume/ast/like_expression.rb +19 -0
  57. data/lib/plume/ast/literal_expression.rb +29 -0
  58. data/lib/plume/ast/literal_false.rb +7 -0
  59. data/lib/plume/ast/literal_nil.rb +7 -0
  60. data/lib/plume/ast/literal_value.rb +18 -0
  61. data/lib/plume/ast/match_clause.rb +15 -0
  62. data/lib/plume/ast/node.rb +207 -0
  63. data/lib/plume/ast/noop_column_constraint.rb +15 -0
  64. data/lib/plume/ast/noop_table_constraint.rb +15 -0
  65. data/lib/plume/ast/not_like_expression.rb +7 -0
  66. data/lib/plume/ast/not_null_column_constraint.rb +17 -0
  67. data/lib/plume/ast/null_column_constraint.rb +17 -0
  68. data/lib/plume/ast/ordering_term.rb +32 -0
  69. data/lib/plume/ast/over_clause.rb +25 -0
  70. data/lib/plume/ast/parenthetical_expression.rb +37 -0
  71. data/lib/plume/ast/parenthetical_source.rb +19 -0
  72. data/lib/plume/ast/pragma_statement.rb +26 -0
  73. data/lib/plume/ast/primary_key_column_constraint.rb +23 -0
  74. data/lib/plume/ast/primary_key_table_constraint.rb +23 -0
  75. data/lib/plume/ast/raise_expression.rb +23 -0
  76. data/lib/plume/ast/reindex_statement.rb +15 -0
  77. data/lib/plume/ast/release_statement.rb +16 -0
  78. data/lib/plume/ast/result_column.rb +18 -0
  79. data/lib/plume/ast/returning_clause.rb +12 -0
  80. data/lib/plume/ast/rollback_statement.rb +19 -0
  81. data/lib/plume/ast/savepoint_statement.rb +16 -0
  82. data/lib/plume/ast/select_core.rb +27 -0
  83. data/lib/plume/ast/select_statement.rb +34 -0
  84. data/lib/plume/ast/star_function_argument.rb +6 -0
  85. data/lib/plume/ast/star_result_column.rb +16 -0
  86. data/lib/plume/ast/strict_table_option.rb +6 -0
  87. data/lib/plume/ast/subquery_expression.rb +14 -0
  88. data/lib/plume/ast/subquery_source.rb +19 -0
  89. data/lib/plume/ast/table_constraint.rb +7 -0
  90. data/lib/plume/ast/table_function.rb +28 -0
  91. data/lib/plume/ast/table_name.rb +28 -0
  92. data/lib/plume/ast/table_option.rb +7 -0
  93. data/lib/plume/ast/ternary_expression.rb +26 -0
  94. data/lib/plume/ast/token.rb +180 -0
  95. data/lib/plume/ast/unary_expression.rb +36 -0
  96. data/lib/plume/ast/unique_column_constraint.rb +17 -0
  97. data/lib/plume/ast/unique_table_constraint.rb +20 -0
  98. data/lib/plume/ast/update_statement.rb +35 -0
  99. data/lib/plume/ast/upsert_clause.rb +26 -0
  100. data/lib/plume/ast/vacuum_statement.rb +18 -0
  101. data/lib/plume/ast/values_clause.rb +12 -0
  102. data/lib/plume/ast/values_row.rb +13 -0
  103. data/lib/plume/ast/variable.rb +15 -0
  104. data/lib/plume/ast/window_definition.rb +26 -0
  105. data/lib/plume/ast/with_clause.rb +15 -0
  106. data/lib/plume/ast/without_rowid_table_option.rb +6 -0
  107. data/lib/plume/catalog.rb +180 -0
  108. data/lib/plume/lexer.rb +639 -0
  109. data/lib/plume/parser.rb +4015 -0
  110. data/lib/plume/printer.rb +471 -0
  111. data/lib/plume/sqlite_grammar.rb +86 -0
  112. data/lib/plume/union.rb +28 -0
  113. data/lib/plume/version.rb +1 -1
  114. data/lib/plume.rb +143 -3
  115. metadata +167 -12
@@ -0,0 +1,574 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plume
4
+ module Analysis
5
+ # Walks a SELECT, works out what every table and column reference points at, and records
6
+ # the reads of base tables. Views and CTEs are looked through: their bodies are analyzed
7
+ # once they are referenced, and everything those bodies read counts as read - whether or
8
+ # not the outer query ends up using it. That matches what SQLite's authorizer reports.
9
+ class Resolver
10
+ # One entry in a FROM clause.
11
+ # kind :table - a base table, reads are recorded against it
12
+ # :derived - CTE, view or subquery, its body has been analyzed already
13
+ # :function - table-valued function, nothing to record
14
+ # :group - an aliased `(a JOIN b) AS x`, columns live in `inner_sources`
15
+ # `columns` is nil when we cannot know them (no catalog, unknown function)
16
+ # `qualified_only` sources answer to `name.column` but never to a bare column name -
17
+ # that is how the `excluded` row of an upsert behaves
18
+ Source = Struct.new(:name, :kind, :schema, :table, :columns, :inner_sources, :merged, :qualified_only, :node, keyword_init: true) do
19
+ def self.table(name, table, node) = new(name:, kind: :table, schema: table.schema, table:, columns: table.columns, node:)
20
+
21
+ def matches?(qualifier, schema_qualifier)
22
+ return false unless name&.casecmp?(qualifier)
23
+ return true unless schema_qualifier
24
+
25
+ schema&.casecmp?(schema_qualifier) || (schema.nil? && schema_qualifier.casecmp?("main"))
26
+ end
27
+
28
+ def column(name)
29
+ return unless columns
30
+ return table.column(name) if table
31
+
32
+ needle = name.downcase
33
+ columns.find { |c| c&.downcase == needle }
34
+ end
35
+ end
36
+
37
+ Scope = Struct.new(:sources, :parent, :ctes, :aliases, keyword_init: true) do
38
+ def each_up
39
+ scope = self
40
+ while scope
41
+ yield scope
42
+ scope = scope.parent
43
+ end
44
+ end
45
+ end
46
+
47
+ Cte = Struct.new(:node, :scope, :analyzed, :columns, :in_progress, keyword_init: true)
48
+
49
+ # The columns of json_each and json_tree are fixed, see https://www.sqlite.org/json1.html#jeach
50
+ JSON_TABLE_COLUMNS = %w[key value type atom id parent fullkey path json root].freeze
51
+ FUNCTION_COLUMNS = {
52
+ "json_each" => JSON_TABLE_COLUMNS,
53
+ "json_tree" => JSON_TABLE_COLUMNS,
54
+ "jsonb_each" => JSON_TABLE_COLUMNS,
55
+ "jsonb_tree" => JSON_TABLE_COLUMNS,
56
+ "generate_series" => %w[value start stop step],
57
+ }.freeze
58
+
59
+ STATEMENTS = [SelectStatement, InsertStatement, UpdateStatement, DeleteStatement].freeze
60
+
61
+ def initialize(catalog: nil)
62
+ @catalog = catalog
63
+ end
64
+
65
+ def analyze(statement)
66
+ start(statement)
67
+
68
+ case statement
69
+ when SelectStatement then select(statement, parent: nil, usage: :projected)
70
+ when InsertStatement then insert(statement)
71
+ when UpdateStatement then update(statement)
72
+ when DeleteStatement then delete(statement)
73
+ end
74
+
75
+ Access.new(reads: @reads, writes: @writes)
76
+ end
77
+
78
+ # Names of the columns a select produces, nil for the ones we cannot name
79
+ def output_columns(statement)
80
+ start(statement)
81
+ select(statement, parent: nil, usage: :projected) || []
82
+ end
83
+
84
+ private
85
+
86
+ def start(statement)
87
+ @reads = Reads.new(statement)
88
+ @writes = Writes.new(statement, @reads.unresolved)
89
+ # By identity: a view holds its parsed select, and hashing that hashes the whole tree
90
+ @expanding_views = Set.new.compare_by_identity
91
+ end
92
+
93
+ def with_scope(with_clause, parent)
94
+ scope = Scope.new(sources: [], parent:, ctes: {})
95
+ with_clause&.ctes&.each do |cte|
96
+ scope.ctes[cte.name.downcase] = Cte.new(node: cte, scope:, analyzed: Set.new)
97
+ end
98
+ scope
99
+ end
100
+
101
+ # -- INSERT, UPDATE, DELETE
102
+
103
+ def insert(statement)
104
+ outer = with_scope(statement.with_clause, nil)
105
+ # The rows being inserted cannot see the target table, upserts and RETURNING can
106
+ select(statement.select, parent: outer, usage: :projected) if statement.select
107
+
108
+ scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
109
+ target = add_target(statement.table, scope)
110
+
111
+ if statement.default_values
112
+ write(target, nil, :insert, statement.table)
113
+ elsif statement.columns
114
+ statement.columns.each { |column| write(target, column.column, :insert, column) }
115
+ elsif target&.columns
116
+ target.columns.each { |column| write(target, column, :insert, statement.table) }
117
+ else
118
+ write(target, "*", :insert, statement.table)
119
+ end
120
+
121
+ statement.upserts&.each do |upsert|
122
+ excluded = Source.new(name: "excluded", kind: :derived, columns: target&.columns, qualified_only: true, node: upsert)
123
+ upsert_scope = Scope.new(sources: [target, excluded].compact, parent: outer, ctes: {}, aliases: {})
124
+
125
+ upsert.targets&.each { |term| expression(term.expression, upsert_scope, :referenced) }
126
+ expression(upsert.target_condition, upsert_scope, :referenced)
127
+ assign(upsert.assignments, target, upsert_scope)
128
+ expression(upsert.condition, upsert_scope, :referenced)
129
+ end
130
+
131
+ returning(statement.returning, scope, target)
132
+ end
133
+
134
+ def update(statement)
135
+ outer = with_scope(statement.with_clause, nil)
136
+ scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
137
+ target = add_target(statement.table, scope)
138
+ add_source(statement.source, scope, :projected) if statement.source
139
+
140
+ assign(statement.assignments, target, scope)
141
+ expression(statement.condition, scope, :referenced)
142
+ returning(statement.returning, scope, target)
143
+ tail(statement, scope)
144
+ end
145
+
146
+ def delete(statement)
147
+ outer = with_scope(statement.with_clause, nil)
148
+ scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
149
+ target = add_target(statement.table, scope)
150
+
151
+ write(target, nil, :delete, statement.table)
152
+ expression(statement.condition, scope, :referenced)
153
+ returning(statement.returning, scope, target)
154
+ tail(statement, scope)
155
+ end
156
+
157
+ # Assigned values end up in the table, so whatever they read counts as projected
158
+ def assign(assignments, target, scope)
159
+ assignments&.each do |assignment|
160
+ assignment.columns.each { |column| write(target, column.column, :update, column) }
161
+ expression(assignment.value, scope, :projected)
162
+ end
163
+ end
164
+
165
+ # A bare * in RETURNING means the target's columns only, not those of an UPDATE's FROM
166
+ def returning(clause, scope, target)
167
+ clause&.columns&.each do |column|
168
+ if StarResultColumn === column && column.table.nil?
169
+ star_source(target, :projected, column, skip_merged: false) if target
170
+ elsif StarResultColumn === column
171
+ star(column, scope, :projected)
172
+ else
173
+ expression(column.result, scope, :projected)
174
+ end
175
+ end
176
+ end
177
+
178
+ def tail(statement, scope)
179
+ statement.orderings&.each { |term| expression(term.expression, scope, :referenced) }
180
+ expression(statement.limit, scope, :referenced)
181
+ expression(statement.offset, scope, :referenced)
182
+ end
183
+
184
+ # The table an INSERT, UPDATE or DELETE writes to. CTEs never stand in for it, and
185
+ # writing to it is not a read, so it joins the scope without recording one
186
+ def add_target(node, scope)
187
+ name = node.alias || node.table
188
+ source =
189
+ if @catalog.nil?
190
+ Source.new(name:, kind: :table, schema: node.schema, node:)
191
+ elsif (table = @catalog.table(node.table, schema: node.schema))
192
+ Source.table(name, table, node)
193
+ elsif (view = @catalog.view(node.table, schema: node.schema))
194
+ # Writable only through INSTEAD OF triggers, which we do not look into
195
+ @reads.views << Analysis.table_key(view.schema, view.name)
196
+ Source.new(name:, kind: :derived, schema: view.schema, columns: view.columns, node:)
197
+ else
198
+ unresolved(node.table, "no such table", node)
199
+ return
200
+ end
201
+ scope.sources << source
202
+ source
203
+ end
204
+
205
+ def write(target, column, action, node)
206
+ return unless target
207
+
208
+ if target.table
209
+ if column && column != "*"
210
+ canonical = target.column(column)
211
+ return unresolved(column, "no such column in #{target.table.name}", node) unless canonical
212
+
213
+ column = canonical
214
+ end
215
+ @writes.references << Reference.new(schema: target.table.schema, table: target.table.name, column:, usage: action, node:)
216
+ else
217
+ @writes.references << Reference.new(schema: target.schema, table: target.node.table, column:, usage: action, node:)
218
+ end
219
+ end
220
+
221
+ def select(statement, parent:, usage:)
222
+ scope = with_scope(statement.with_clause, parent)
223
+
224
+ names = nil
225
+ core_scope = nil
226
+ statement.cores.each do |core|
227
+ core_names, core_scope = select_core(core, parent: scope, usage:)
228
+ names ||= core_names
229
+ end
230
+
231
+ # ORDER BY of a compound can only name result columns, which reads nothing new
232
+ if statement.cores.size == 1 && core_scope
233
+ statement.orderings&.each do |term|
234
+ expression(term.expression, core_scope, :referenced, aliases: :first)
235
+ end
236
+ end
237
+ expression(statement.limit, scope, :referenced)
238
+ expression(statement.offset, scope, :referenced)
239
+
240
+ names
241
+ end
242
+
243
+ def select_core(core, parent:, usage:)
244
+ scope = Scope.new(sources: [], parent:, ctes: {}, aliases: {})
245
+
246
+ if ValuesClause === core
247
+ core.rows.each { |row| row.values.each { |v| expression(v, scope, usage) } }
248
+ width = core.rows.first.values.size
249
+ return [Array.new(width) { |i| "column#{i + 1}" }, scope]
250
+ end
251
+
252
+ add_source(core.source, scope, usage) if core.source
253
+
254
+ core.columns.each do |column|
255
+ scope.aliases[column.alias.downcase] = column.result if ResultColumn === column && column.alias
256
+ end
257
+
258
+ names = []
259
+ core.columns.each do |column|
260
+ if StarResultColumn === column
261
+ expanded = star(column, scope, usage)
262
+ expanded ? names.concat(expanded) : names << nil
263
+ else
264
+ expression(column.result, scope, usage)
265
+ names << output_name(column)
266
+ end
267
+ end
268
+
269
+ expression(core.condition, scope, :referenced, aliases: :fallback)
270
+ core.groupings&.each { |g| expression(g, scope, :referenced, aliases: :fallback) }
271
+ expression(core.having, scope, :referenced, aliases: :fallback)
272
+ core.windows&.each do |window|
273
+ window.partitions&.each { |p| expression(p, scope, :referenced) }
274
+ window.orderings&.each { |o| expression(o.expression, scope, :referenced) }
275
+ end
276
+
277
+ [names, scope]
278
+ end
279
+
280
+ def output_name(column)
281
+ return column.alias if column.alias
282
+
283
+ case (result = column.result)
284
+ when ColumnName then result.column
285
+ when Node then result.source_text
286
+ end
287
+ end
288
+
289
+ # -- FROM
290
+
291
+ def add_source(source, scope, usage)
292
+ case source
293
+ when JoinClause
294
+ add_source(source.left, scope, usage)
295
+ source.joins.each do |join|
296
+ left_sources = scope.sources.dup
297
+ add_source(join.source, scope, usage)
298
+ join_constraint(join, left_sources, scope.sources.last, scope)
299
+ end
300
+ when TableName then add_table_source(source, scope, usage)
301
+ when TableFunction
302
+ source.arguments&.each { |a| expression(a, scope, :referenced) }
303
+ # Any virtual table can be called like this, `fts('query')` reads the fts table
304
+ if @catalog && (table = @catalog.table(source.function, schema: source.schema))
305
+ record(table.schema, table.name, nil, usage, source)
306
+ scope.sources << Source.table(source.alias || source.function, table, source)
307
+ return
308
+ end
309
+
310
+ @reads.functions << source.function.downcase
311
+ scope.sources << Source.new(
312
+ name: source.alias || source.function, kind: :function,
313
+ columns: FUNCTION_COLUMNS[source.function.downcase], node: source,
314
+ )
315
+ when SubquerySource
316
+ # A FROM subquery sees the enclosing queries, but not its siblings in this FROM
317
+ names = select(source.select, parent: scope.parent, usage:)
318
+ scope.sources << Source.new(name: source.alias, kind: :derived, columns: names, node: source)
319
+ when ParentheticalSource
320
+ if source.alias
321
+ inner = Scope.new(sources: [], parent: scope.parent, ctes: {})
322
+ add_source(source.source, inner, usage)
323
+ scope.sources << Source.new(name: source.alias, kind: :group, inner_sources: inner.sources, node: source)
324
+ else
325
+ add_source(source.source, scope, usage)
326
+ end
327
+ end
328
+ end
329
+
330
+ def add_table_source(source, scope, usage)
331
+ name = source.alias || source.table
332
+
333
+ if source.schema.nil? && (cte = find_cte(source.table, scope))
334
+ columns = analyze_cte(cte, usage)
335
+ scope.sources << Source.new(name:, kind: :derived, columns:, node: source)
336
+ elsif @catalog.nil?
337
+ record(source.schema, source.table, nil, usage, source)
338
+ scope.sources << Source.new(name:, kind: :table, schema: source.schema, table: nil, node: source)
339
+ elsif (table = @catalog.table(source.table, schema: source.schema))
340
+ record(table.schema, table.name, nil, usage, source)
341
+ scope.sources << Source.table(name, table, source)
342
+ elsif (view = @catalog.view(source.table, schema: source.schema))
343
+ @reads.views << Analysis.table_key(view.schema, view.name)
344
+ if @expanding_views.include?(view)
345
+ unresolved(source.table, "circularly defined view", source)
346
+ else
347
+ @expanding_views << view
348
+ # A view's body starts a scope chain of its own, so the CTEs of the query using it stay out of sight
349
+ names = select(view.select, parent: nil, usage:)
350
+ @expanding_views.delete(view)
351
+ end
352
+ scope.sources << Source.new(name:, kind: :derived, columns: view.columns || names, node: source)
353
+ else
354
+ unresolved(source.table, "no such table", source)
355
+ scope.sources << Source.new(name:, kind: :table, schema: source.schema, node: source)
356
+ end
357
+ end
358
+
359
+ def find_cte(name, scope)
360
+ needle = name.downcase
361
+ scope.each_up do |s|
362
+ return s.ctes[needle] if s.ctes&.key?(needle)
363
+ end
364
+ nil
365
+ end
366
+
367
+ def analyze_cte(cte, usage)
368
+ declared = cte.node.column_names
369
+ # A recursive CTE refers to itself, we know its columns at best from the declaration
370
+ return declared || cte.columns if cte.in_progress || cte.analyzed.include?(usage)
371
+
372
+ cte.in_progress = true
373
+ names = select(cte.node.select, parent: cte.scope, usage:)
374
+ cte.in_progress = false
375
+ cte.analyzed << usage
376
+ cte.columns = names
377
+ declared || names
378
+ end
379
+
380
+ def join_constraint(join, left_sources, right, scope)
381
+ shared =
382
+ case join.constraint
383
+ when JoinUsingConstraint then join.constraint.column_names
384
+ else
385
+ if join.operator.natural
386
+ if right.columns.nil? || left_sources.any? { |s| s.columns.nil? }
387
+ unresolved(right.name.to_s, "cannot work out the columns of a NATURAL JOIN without a catalog", join)
388
+ []
389
+ else
390
+ right.columns.select { |c| left_sources.any? { |s| s.column(c) } }
391
+ end
392
+ else
393
+ expression(join.constraint.expression, scope, :referenced) if JoinOnConstraint === join.constraint
394
+ []
395
+ end
396
+ end
397
+
398
+ # Both sides of USING and NATURAL are compared, even though SQLite's authorizer
399
+ # does not report the right-hand one
400
+ shared.each do |column|
401
+ left = left_sources.find { |s| s.column(column) } || left_sources.find { |s| s.columns.nil? }
402
+ read_through(left, column, :referenced, join) if left
403
+ read_through(right, column, :referenced, join)
404
+ (right.merged ||= Set.new) << column.downcase
405
+ end
406
+ end
407
+
408
+ def star(column, scope, usage)
409
+ sources =
410
+ if column.table
411
+ found = scope.sources.find { |s| s.matches?(column.table, nil) }
412
+ unless found
413
+ unresolved("#{column.table}.*", "no such table", column)
414
+ return nil
415
+ end
416
+ [found]
417
+ else
418
+ scope.sources
419
+ end
420
+
421
+ names = []
422
+ sources.each do |source|
423
+ expanded = star_source(source, usage, column, skip_merged: column.table.nil?)
424
+ expanded ? names.concat(expanded) : (return nil)
425
+ end
426
+ names
427
+ end
428
+
429
+ def star_source(source, usage, node, skip_merged:)
430
+ case source.kind
431
+ when :group
432
+ source.inner_sources.flat_map { |m| star_source(m, usage, node, skip_merged:) || [] }
433
+ when :table
434
+ if source.columns.nil?
435
+ record(source.schema, source.node.table, "*", usage, node)
436
+ nil
437
+ else
438
+ columns = source.columns.reject { |c| skip_merged && source.merged&.include?(c.downcase) }
439
+ columns.each { |c| record(source.table.schema, source.table.name, c, usage, node) }
440
+ columns
441
+ end
442
+ else
443
+ source.columns&.reject { |c| skip_merged && c && source.merged&.include?(c.downcase) }
444
+ end
445
+ end
446
+
447
+ # -- expressions
448
+
449
+ def expression(node, scope, usage, aliases: :none)
450
+ case node
451
+ when nil, Variable, LiteralExpression then nil
452
+ when ColumnName then column(node, scope, usage, aliases)
453
+ when SelectStatement then select(node, parent: scope, usage:)
454
+ when TableName then whole_table(node, scope)
455
+ when Node then node.children.each { |child| expression(child, scope, usage, aliases:) }
456
+ end
457
+ end
458
+
459
+ # `x IN t` compares against the single column of t
460
+ def whole_table(node, scope)
461
+ fake = Scope.new(sources: [], parent: scope, ctes: {})
462
+ add_table_source(node, fake, :referenced)
463
+ source = fake.sources.last
464
+ star_source(source, :referenced, node, skip_merged: false) if source
465
+ end
466
+
467
+ def column(node, scope, usage, aliases)
468
+ name = node.column
469
+
470
+ if node.table
471
+ scope.each_up do |s|
472
+ source = s.sources.find { |src| src.matches?(node.table, node.schema) }
473
+ return read_through(source, name, usage, node) if source
474
+ end
475
+ return unresolved(qualified_name(node), "no such table: #{node.table}", node)
476
+ end
477
+
478
+ return if aliases == :first && scope.aliases&.key?(name.downcase)
479
+
480
+ scope.each_up do |s|
481
+ return if resolve_in(s, name, usage, node)
482
+ return if s.equal?(scope) && aliases == :fallback && s.aliases&.key?(name.downcase)
483
+ return if rowid(s, name, usage, node)
484
+ end
485
+
486
+ # SQLite reads an unresolvable "double-quoted" identifier as a string literal
487
+ return if node.column_tk_src&.start_with?('"')
488
+
489
+ unresolved(name, "no such column", node)
490
+ end
491
+
492
+ def resolve_in(scope, name, usage, node)
493
+ candidates = scope.sources.reject(&:qualified_only)
494
+ known = candidates.select { |s| source_has?(s, name) }
495
+ unknown = candidates.select { |s| source_unknown?(s) }
496
+
497
+ if known.size == 1 && unknown.empty?
498
+ read_through(known.first, name, usage, node)
499
+ elsif known.size > 1 && unknown.empty?
500
+ # A USING/NATURAL column belongs to both sides and is not ambiguous
501
+ merged = known.select { |s| s.merged&.include?(name.downcase) }
502
+ if merged.any?
503
+ read_through((known - merged).first || known.first, name, usage, node)
504
+ else
505
+ unresolved(name, "ambiguous column name", node)
506
+ end
507
+ elsif known.empty? && unknown.size == 1
508
+ read_through(unknown.first, name, usage, node)
509
+ elsif unknown.any? && (known.size + unknown.size) > 1
510
+ unresolved(name, "ambiguous column name without a catalog", node)
511
+ else
512
+ return false
513
+ end
514
+ true
515
+ end
516
+
517
+ def source_has?(source, name)
518
+ return source.inner_sources.any? { |m| source_has?(m, name) } if source.kind == :group
519
+
520
+ !!source.column(name)
521
+ end
522
+
523
+ def source_unknown?(source)
524
+ return source.inner_sources.any? { |m| source_unknown?(m) } if source.kind == :group
525
+
526
+ source.columns.nil?
527
+ end
528
+
529
+ def rowid(scope, name, usage, node)
530
+ return false unless Catalog::ROWID_NAMES.include?(name.downcase)
531
+
532
+ candidates = scope.sources.select { |s| s.kind == :table && (s.table.nil? || s.table.rowid?) }
533
+ return false unless candidates.size == 1
534
+
535
+ read_through(candidates.first, name, usage, node)
536
+ true
537
+ end
538
+
539
+ def read_through(source, name, usage, node)
540
+ case source.kind
541
+ when :table
542
+ if source.table
543
+ column = source.table.column(name)
544
+ column ||= source.table.rowid_alias || "rowid" if Catalog::ROWID_NAMES.include?(name.downcase) && source.table.rowid?
545
+ return unresolved(name, "no such column in #{source.table.name}", node) unless column
546
+
547
+ record(source.table.schema, source.table.name, column, usage, node)
548
+ else
549
+ record(source.schema, source.node.table, name, usage, node)
550
+ end
551
+ when :group
552
+ member = source.inner_sources.find { |m| source_has?(m, name) } || source.inner_sources.find { |m| source_unknown?(m) }
553
+ member ? read_through(member, name, usage, node) : unresolved(name, "no such column", node)
554
+ when :derived, :function
555
+ if source.columns && !source.column(name) && !source.columns.include?(nil)
556
+ unresolved(name, "no such column in #{source.name}", node)
557
+ end
558
+ end
559
+ true
560
+ end
561
+
562
+ def record(schema, table, column, usage, node)
563
+ @reads.references << Reference.new(schema:, table:, column:, usage:, node:)
564
+ end
565
+
566
+ def unresolved(name, reason, node)
567
+ @reads.unresolved << Unresolved.new(name:, reason:, node:)
568
+ true
569
+ end
570
+
571
+ def qualified_name(node) = [node.schema, node.table, node.column].compact.join(".")
572
+ end
573
+ end
574
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plume
4
+ # Static analysis on top of the AST. The first question it answers is "which tables and
5
+ # columns would this statement read?", see `Plume.reads`.
6
+ module Analysis
7
+ autoload :Resolver, "plume/analysis/resolver"
8
+
9
+ # A read or write of one column, or of a table as a whole when `column` is nil - which is
10
+ # what `SELECT count(*) FROM t` or `DELETE FROM t` amount to.
11
+ #
12
+ # For reads, `usage` is `:projected` when the value itself can leave the statement - in the
13
+ # result rows, through RETURNING, or copied into a column being written - and `:referenced`
14
+ # when it is only used to filter, join, sort or group. For writes it is `:insert`,
15
+ # `:update` or `:delete`.
16
+ Reference = Data.define(:schema, :table, :column, :usage, :node) do
17
+ def table_key = Analysis.table_key(schema, table)
18
+ end
19
+
20
+ Unresolved = Data.define(:name, :reason, :node) do
21
+ def message
22
+ excerpt = Analysis.excerpt(node)
23
+ excerpt ? "#{reason}: #{name}\n#{excerpt}" : "#{reason}: #{name}"
24
+ end
25
+ end
26
+
27
+ # The source line `node` sits on with a caret under it, for error messages
28
+ def self.excerpt(node)
29
+ loc = node&.location
30
+ Plume.excerpt(node.full_source, *loc) if loc && node.full_source
31
+ end
32
+
33
+ class Error < StandardError
34
+ attr_reader :unresolved
35
+
36
+ def initialize(message = nil, unresolved: [])
37
+ @unresolved = unresolved
38
+ super(message || unresolved.map(&:message).join("\n"))
39
+ end
40
+ end
41
+
42
+ # Tables in the main schema are keyed by their bare name, any other schema
43
+ # (temp, attached databases) is spelled out as "schema.table"
44
+ def self.table_key(schema, table)
45
+ (schema.nil? || schema.casecmp?("main")) ? table : "#{schema}.#{table}"
46
+ end
47
+
48
+ class References
49
+ attr_reader :statement, :references, :unresolved
50
+
51
+ def initialize(statement, unresolved = [])
52
+ @statement = statement
53
+ @references = []
54
+ @unresolved = unresolved
55
+ end
56
+
57
+ # Every base table touched, including ones touched only as a whole
58
+ def tables(usage = nil) = @references.select { |r| usage.nil? || r.usage == usage }.to_set(&:table_key)
59
+
60
+ # `{ "table" => Set["column", …] }`, optionally narrowed to one usage
61
+ def columns(usage = nil)
62
+ @references.each_with_object({}) do |ref, acc|
63
+ next if ref.column.nil? || (usage && ref.usage != usage)
64
+
65
+ (acc[ref.table_key] ||= Set.new) << ref.column
66
+ end
67
+ end
68
+
69
+ def resolved? = @unresolved.empty?
70
+
71
+ def inspect
72
+ "#<#{self.class.name} columns=#{columns.transform_values(&:to_a).inspect} unresolved=#{@unresolved.size}>"
73
+ end
74
+ end
75
+
76
+ class Reads < References
77
+ attr_reader :views, :functions
78
+
79
+ def initialize(...)
80
+ super
81
+ @views = Set.new
82
+ @functions = Set.new
83
+ end
84
+
85
+ def projected = columns(:projected)
86
+ def referenced = columns(:referenced)
87
+ end
88
+
89
+ class Writes < References
90
+ # INSERT without a column list writes every column; without a catalog that is "*"
91
+ def inserted = columns(:insert)
92
+ def updated = columns(:update)
93
+ def deleted = tables(:delete)
94
+ end
95
+
96
+ Access = Data.define(:reads, :writes) do
97
+ def unresolved = reads.unresolved
98
+ def resolved? = reads.resolved?
99
+ end
100
+ end
101
+
102
+ module Analysis
103
+ # Which tables and columns `sql` - a single SELECT, INSERT, UPDATE or DELETE - would read.
104
+ # Instead of SQL you can pass what you already parsed: a statement node, or the array
105
+ # `Plume.parse` returns, so the same SQL does not get parsed twice.
106
+ #
107
+ # Without a catalog, columns of tables cannot be enumerated: `SELECT *` comes back as a read
108
+ # of the column "*", and an unqualified column with more than one table in scope is ambiguous.
109
+ #
110
+ # With `strict: true` (the default) anything that cannot be attributed raises
111
+ # `Plume::Analysis::Error`; with `strict: false` it is listed in `Reads#unresolved` instead.
112
+ def self.reads(sql, catalog: nil, strict: true) = access(sql, catalog:, strict:).reads
113
+
114
+ # Like `reads`, but also reports what an INSERT, UPDATE or DELETE writes
115
+ def self.access(sql, catalog: nil, strict: true)
116
+ statements =
117
+ case sql
118
+ when Node then [sql]
119
+ when Array then sql
120
+ else Parser.new(sql).parse
121
+ end
122
+ raise Error, "expected exactly one statement, got #{statements.size}" unless statements.size == 1
123
+
124
+ statement = statements.first
125
+ unless Resolver::STATEMENTS.any? { |klass| klass === statement }
126
+ raise Error, "only SELECT, INSERT, UPDATE and DELETE can be analyzed, got #{statement.class.name}"
127
+ end
128
+
129
+ access = Resolver.new(catalog:).analyze(statement)
130
+ raise Error.new(unresolved: access.unresolved) if strict && !access.resolved?
131
+
132
+ access
133
+ end
134
+ end
135
+ end