plume 0.1.0 → 0.3.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +506 -11
  3. data/lib/plume/analysis/resolver.rb +797 -0
  4. data/lib/plume/analysis.rb +211 -0
  5. data/lib/plume/ast/alter_table_statement.rb +40 -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 +20 -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 +26 -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/deferrable_column_constraint.rb +20 -0
  34. data/lib/plume/ast/delete_statement.rb +28 -0
  35. data/lib/plume/ast/detach_statement.rb +13 -0
  36. data/lib/plume/ast/drop_statement.rb +21 -0
  37. data/lib/plume/ast/empty_function_argument.rb +6 -0
  38. data/lib/plume/ast/exists_expression.rb +15 -0
  39. data/lib/plume/ast/explain_statement.rb +15 -0
  40. data/lib/plume/ast/expression.rb +30 -0
  41. data/lib/plume/ast/foreign_key_action.rb +26 -0
  42. data/lib/plume/ast/foreign_key_clause.rb +50 -0
  43. data/lib/plume/ast/foreign_key_column_constraint.rb +16 -0
  44. data/lib/plume/ast/foreign_key_table_constraint.rb +20 -0
  45. data/lib/plume/ast/frame_boundary.rb +35 -0
  46. data/lib/plume/ast/frame_spec.rb +34 -0
  47. data/lib/plume/ast/function_arguments.rb +18 -0
  48. data/lib/plume/ast/function_reference.rb +29 -0
  49. data/lib/plume/ast/generated_as_column_constraint.rb +24 -0
  50. data/lib/plume/ast/indexed_column.rb +24 -0
  51. data/lib/plume/ast/insert_statement.rb +35 -0
  52. data/lib/plume/ast/join.rb +12 -0
  53. data/lib/plume/ast/join_clause.rb +15 -0
  54. data/lib/plume/ast/join_on_constraint.rb +12 -0
  55. data/lib/plume/ast/join_operator.rb +31 -0
  56. data/lib/plume/ast/join_using_constraint.rb +16 -0
  57. data/lib/plume/ast/like_expression.rb +19 -0
  58. data/lib/plume/ast/literal_expression.rb +29 -0
  59. data/lib/plume/ast/literal_false.rb +7 -0
  60. data/lib/plume/ast/literal_nil.rb +7 -0
  61. data/lib/plume/ast/literal_value.rb +18 -0
  62. data/lib/plume/ast/match_clause.rb +15 -0
  63. data/lib/plume/ast/node.rb +330 -0
  64. data/lib/plume/ast/noop_column_constraint.rb +15 -0
  65. data/lib/plume/ast/noop_table_constraint.rb +15 -0
  66. data/lib/plume/ast/not_like_expression.rb +7 -0
  67. data/lib/plume/ast/not_null_column_constraint.rb +17 -0
  68. data/lib/plume/ast/null_column_constraint.rb +17 -0
  69. data/lib/plume/ast/ordering_term.rb +32 -0
  70. data/lib/plume/ast/over_clause.rb +25 -0
  71. data/lib/plume/ast/parenthetical_expression.rb +37 -0
  72. data/lib/plume/ast/parenthetical_source.rb +19 -0
  73. data/lib/plume/ast/pragma_statement.rb +26 -0
  74. data/lib/plume/ast/primary_key_column_constraint.rb +23 -0
  75. data/lib/plume/ast/primary_key_table_constraint.rb +23 -0
  76. data/lib/plume/ast/raise_expression.rb +23 -0
  77. data/lib/plume/ast/reindex_statement.rb +15 -0
  78. data/lib/plume/ast/release_statement.rb +16 -0
  79. data/lib/plume/ast/result_column.rb +18 -0
  80. data/lib/plume/ast/returning_clause.rb +12 -0
  81. data/lib/plume/ast/rollback_statement.rb +19 -0
  82. data/lib/plume/ast/savepoint_statement.rb +16 -0
  83. data/lib/plume/ast/select_core.rb +27 -0
  84. data/lib/plume/ast/select_statement.rb +34 -0
  85. data/lib/plume/ast/star_function_argument.rb +6 -0
  86. data/lib/plume/ast/star_result_column.rb +16 -0
  87. data/lib/plume/ast/strict_table_option.rb +6 -0
  88. data/lib/plume/ast/subquery_expression.rb +14 -0
  89. data/lib/plume/ast/subquery_source.rb +19 -0
  90. data/lib/plume/ast/table_constraint.rb +7 -0
  91. data/lib/plume/ast/table_function.rb +28 -0
  92. data/lib/plume/ast/table_name.rb +28 -0
  93. data/lib/plume/ast/table_option.rb +7 -0
  94. data/lib/plume/ast/ternary_expression.rb +26 -0
  95. data/lib/plume/ast/token.rb +183 -0
  96. data/lib/plume/ast/unary_expression.rb +36 -0
  97. data/lib/plume/ast/unique_column_constraint.rb +17 -0
  98. data/lib/plume/ast/unique_table_constraint.rb +20 -0
  99. data/lib/plume/ast/update_statement.rb +35 -0
  100. data/lib/plume/ast/upsert_clause.rb +26 -0
  101. data/lib/plume/ast/vacuum_statement.rb +18 -0
  102. data/lib/plume/ast/values_clause.rb +12 -0
  103. data/lib/plume/ast/values_row.rb +13 -0
  104. data/lib/plume/ast/variable.rb +15 -0
  105. data/lib/plume/ast/window_definition.rb +26 -0
  106. data/lib/plume/ast/with_clause.rb +15 -0
  107. data/lib/plume/ast/without_rowid_table_option.rb +6 -0
  108. data/lib/plume/catalog.rb +276 -0
  109. data/lib/plume/comment.rb +73 -0
  110. data/lib/plume/dump.rb +143 -0
  111. data/lib/plume/fingerprint.rb +86 -0
  112. data/lib/plume/lexer.rb +640 -0
  113. data/lib/plume/literal_patches.rb +76 -0
  114. data/lib/plume/normalize.rb +65 -0
  115. data/lib/plume/parameters.rb +52 -0
  116. data/lib/plume/parser.rb +4064 -0
  117. data/lib/plume/pretty_printer.rb +202 -0
  118. data/lib/plume/printer.rb +525 -0
  119. data/lib/plume/sqlite_grammar.rb +86 -0
  120. data/lib/plume/truncate.rb +67 -0
  121. data/lib/plume/union.rb +28 -0
  122. data/lib/plume/version.rb +1 -1
  123. data/lib/plume.rb +274 -3
  124. metadata +176 -12
@@ -0,0 +1,797 @@
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)&.zero?
23
+ return true unless schema_qualifier
24
+
25
+ schema&.casecmp(schema_qualifier)&.zero? || (schema.nil? && schema_qualifier.casecmp("main")&.zero?)
26
+ end
27
+
28
+ def column(name)
29
+ return unless columns
30
+ return table.column(name) if table
31
+
32
+ needle = name.downcase(:ascii)
33
+ columns.find { |c| c&.downcase(:ascii) == 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].freeze,
57
+ }.freeze
58
+
59
+ STATEMENTS = [SelectStatement, InsertStatement, UpdateStatement, DeleteStatement, CreateTriggerStatement].freeze # analyzed for reads and writes
60
+
61
+ # What SQLite calls for these operators and keywords, as its authorizer names them
62
+ OPERATOR_FUNCTIONS = {
63
+ LIKE: "like", NOT_LIKE: "like", GLOB: "glob", NOT_GLOB: "glob", REGEXP: "regexp", NOT_REGEXP: "regexp",
64
+ MATCH: "match", NOT_MATCH: "match", EXTRACT: "->", RETRIEVE: "->>",
65
+ }.freeze
66
+
67
+ # SQLite's own limit, which also keeps resolving names across a join from going quadratic
68
+ MAX_JOIN_TABLES = 64
69
+
70
+ def initialize(catalog: nil)
71
+ @catalog = catalog
72
+ end
73
+
74
+ def analyze(statement)
75
+ start(statement)
76
+
77
+ case statement
78
+ when SelectStatement then select(statement, parent: nil, usage: :projected)
79
+ when InsertStatement then insert(statement)
80
+ when UpdateStatement then update(statement)
81
+ when DeleteStatement then delete(statement)
82
+ when CreateTriggerStatement then trigger(statement)
83
+ when ExplainStatement then return analyze(statement.statement)
84
+ else schema_statement(statement)
85
+ end
86
+
87
+ Access.new(reads: @reads, writes: @writes, actions: @actions.freeze)
88
+ end
89
+
90
+ # Names of the columns a select produces, nil for the ones we cannot name
91
+ def output_columns(statement)
92
+ start(statement)
93
+ select(statement, parent: nil, usage: :projected) || []
94
+ end
95
+
96
+ private
97
+
98
+ def start(statement)
99
+ @reads = Reads.new(statement)
100
+ @writes = Writes.new(statement, @reads.unresolved, @reads.ctes, @reads.aliases)
101
+ @actions = []
102
+ # By identity: a view holds its parsed select, and hashing that hashes the whole tree
103
+ @expanding_views = Set.new.compare_by_identity
104
+ end
105
+
106
+ def with_scope(with_clause, parent)
107
+ scope = Scope.new(sources: [], parent:, ctes: {})
108
+ with_clause&.ctes&.each do |cte|
109
+ scope.ctes[cte.name.downcase(:ascii)] = Cte.new(node: cte, scope:, analyzed: Set.new)
110
+ @reads.ctes << cte.name if @expanding_views.empty?
111
+ end
112
+ scope
113
+ end
114
+
115
+ # -- INSERT, UPDATE, DELETE
116
+
117
+ def insert(statement, parent: nil)
118
+ outer = with_scope(statement.with_clause, parent)
119
+ # The rows being inserted cannot see the target table, upserts and RETURNING can
120
+ select(statement.select, parent: outer, usage: :projected) if statement.select
121
+
122
+ scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
123
+ target = add_target(statement.table, scope)
124
+
125
+ if statement.default_values
126
+ write(target, nil, :insert, statement.table)
127
+ elsif statement.columns
128
+ statement.columns.each { |column| write(target, column.column, :insert, column) }
129
+ elsif target&.columns
130
+ # A VALUES row without a column list has no slot for generated columns
131
+ (target.columns - (target.table&.generated_columns || [])).each { |column| write(target, column, :insert, statement.table) }
132
+ else
133
+ write(target, "*", :insert, statement.table)
134
+ end
135
+
136
+ statement.upserts&.each do |upsert|
137
+ excluded = Source.new(name: "excluded", kind: :derived, columns: target&.columns, qualified_only: true, node: upsert)
138
+ upsert_scope = Scope.new(sources: [target, excluded].compact, parent: outer, ctes: {}, aliases: {})
139
+
140
+ upsert.targets&.each { |term| expression(term.expression, upsert_scope, :referenced) }
141
+ expression(upsert.target_condition, upsert_scope, :referenced)
142
+ assign(upsert.assignments, target, upsert_scope)
143
+ expression(upsert.condition, upsert_scope, :referenced)
144
+ end
145
+
146
+ returning(statement.returning, scope, target)
147
+ end
148
+
149
+ def update(statement, parent: nil)
150
+ outer = with_scope(statement.with_clause, parent)
151
+ scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
152
+ target = add_target(statement.table, scope)
153
+ if statement.source
154
+ # Pairing the target's rows with FROM's reads the target, named columns or not
155
+ record(target.schema, target.table&.name || statement.table.table, nil, :referenced, statement.table) if target&.kind == :table
156
+ add_from(statement.source, scope, :projected)
157
+ end
158
+
159
+ assign(statement.assignments, target, scope)
160
+ expression(statement.condition, scope, :referenced)
161
+ returning(statement.returning, scope, target)
162
+ tail(statement, scope)
163
+ end
164
+
165
+ def delete(statement, parent: nil)
166
+ outer = with_scope(statement.with_clause, parent)
167
+ scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
168
+ target = add_target(statement.table, scope)
169
+
170
+ write(target, nil, :delete, statement.table)
171
+ expression(statement.condition, scope, :referenced)
172
+ returning(statement.returning, scope, target)
173
+ tail(statement, scope)
174
+ end
175
+
176
+ # -- schema changes and the rest
177
+
178
+ def schema_statement(statement)
179
+ case statement
180
+ when CreateTableStatement
181
+ schema = new_schema(statement.table, statement.temporary)
182
+ act(:create_table, temp: schema, name: statement.table.name, node: statement)
183
+ Catalog.autoindexes(statement).each { |index| act(:create_index, temp: schema, name: index, detail: statement.table.name, node: statement) }
184
+ select(statement.select_statement, parent: nil, usage: :projected) if statement.select_statement
185
+ when CreateViewStatement
186
+ # SQLite checks a view's body when the view is used, not when it is made
187
+ act(:create_view, temp: new_schema(statement.view, statement.temporary), name: statement.view.name, node: statement)
188
+ when CreateIndexStatement then create_index(statement)
189
+ when CreateVirtualTableStatement
190
+ act(:create_vtable, name: statement.table.name, detail: statement.module_name, schema: new_schema(statement.table, false), node: statement)
191
+ when DropStatement then drop(statement)
192
+ when AlterTableStatement
193
+ table = @catalog&.table(statement.table.table, schema: statement.table.schema)
194
+ detail = statement.column if statement.action == :DROP_COLUMN
195
+ act(:alter_table, name: table&.name || statement.table.table, detail:, schema: table&.schema || statement.table.schema || "main", node: statement)
196
+ when PragmaStatement
197
+ act(:pragma, name: statement.name, detail: pragma_value(statement), schema: statement.schema, node: statement)
198
+ when BeginStatement then act(:transaction, name: "BEGIN", node: statement)
199
+ when CommitStatement then act(:transaction, name: "COMMIT", node: statement)
200
+ when RollbackStatement
201
+ if statement.savepoint
202
+ act(:savepoint, name: "ROLLBACK", detail: statement.savepoint, node: statement)
203
+ else
204
+ act(:transaction, name: "ROLLBACK", node: statement)
205
+ end
206
+ when SavepointStatement then act(:savepoint, name: "BEGIN", detail: statement.name, node: statement)
207
+ when ReleaseStatement then act(:savepoint, name: "RELEASE", detail: statement.name, node: statement)
208
+ when AttachStatement then act(:attach, name: plain_value(statement.file), node: statement)
209
+ when DetachStatement then act(:detach, name: plain_value(statement.schema), node: statement)
210
+ when ReindexStatement then reindex(statement)
211
+ when AnalyzeStatement then analyze_tables(statement)
212
+ end
213
+ end
214
+
215
+ def create_index(statement)
216
+ schema = statement.index.schema
217
+ table_node = TableName.new(schema:, table: statement.table)
218
+ act(:create_index, temp: new_schema(statement.index, false, on: table_node), name: statement.index.name, detail: statement.table, node: statement)
219
+ scope = Scope.new(sources: [], parent: nil, ctes: {}, aliases: {})
220
+ add_table_source(table_node, scope, :referenced)
221
+ # The table's rows are read to fill the index, which only an authorizer sees as columns
222
+ @reads.references.pop if @reads.references.last&.column.nil?
223
+ statement.columns&.each { |term| expression(term.expression, scope, :referenced) }
224
+ expression(statement.condition, scope, :referenced)
225
+ act(:reindex, name: statement.index.name, schema: @actions.last.schema, node: statement)
226
+ end
227
+
228
+ def drop(statement)
229
+ target = statement.target
230
+ case statement.type
231
+ when :TABLE
232
+ table = @catalog&.table(target.table, schema: target.schema)
233
+ if table&.module_name
234
+ act(:drop_vtable, name: table.name, detail: table.module_name, schema: table.schema, node: statement)
235
+ else
236
+ act(:drop_table, temp: table&.schema || target.schema || "main", name: table&.name || target.table, node: statement)
237
+ end
238
+ @writes.references << Reference.new(schema: table&.schema || target.schema, table: table&.name || target.table, column: nil, usage: :delete, node: statement)
239
+ drop_triggers(table, statement)
240
+ when :VIEW
241
+ view = @catalog&.view(target.table, schema: target.schema)
242
+ act(:drop_view, temp: view&.schema || target.schema || "main", name: view&.name || target.table, node: statement)
243
+ drop_triggers(view, statement)
244
+ when :INDEX
245
+ index = @catalog&.index(target.table, schema: target.schema)
246
+ act(:drop_index, temp: index&.schema || target.schema || "main", name: index&.name || target.table, detail: index&.table, node: statement)
247
+ when :TRIGGER
248
+ trigger = @catalog&.trigger(target.table, schema: target.schema)
249
+ act(:drop_trigger, temp: trigger&.schema || target.schema || "main", name: trigger&.name || target.table, detail: trigger&.table, node: statement)
250
+ end
251
+ end
252
+
253
+ # Dropping a table or view drops its triggers, newest first
254
+ def drop_triggers(object, statement)
255
+ return unless object
256
+
257
+ triggers = @catalog.triggers.select { |t| Catalog.same_name?(t.schema, object.schema) && Catalog.same_name?(t.table, object.name) }
258
+ triggers.reverse_each { |t| act(:drop_trigger, temp: t.schema, name: t.name, detail: t.table, node: statement) }
259
+ end
260
+
261
+ # `temp:` takes the schema and makes the action its _temp_ variant when that schema is temp
262
+ def act(action, node:, name: nil, detail: nil, schema: nil, temp: nil)
263
+ if temp
264
+ schema = temp
265
+ action = action.name.sub(/\A(create|drop)_/) { "#{$1}_temp_" }.to_sym if Catalog.same_name?(temp, "temp")
266
+ end
267
+ @actions << Action.new(action:, name:, detail:, schema:, node:)
268
+ end
269
+
270
+ # Where a new object goes: the schema it names, temp for TEMP, or the schema of the table it is on
271
+ def new_schema(name, temporary, on: nil)
272
+ return name.schema if name.schema
273
+ return "temp" if temporary
274
+ return @catalog&.table(on.table)&.schema || "main" if on
275
+
276
+ "main"
277
+ end
278
+
279
+ # REINDEX names an index, a table whose indexes to rebuild, or a collation - and SQLite's
280
+ # authorizer is asked about each index rebuilt. Without a catalog only a name is known
281
+ def reindex(statement)
282
+ target = statement.target
283
+ return target && act(:reindex, name: target.table, schema: target.schema || "main", node: statement) unless @catalog
284
+
285
+ indexes =
286
+ if target.nil? then @catalog.indexes
287
+ elsif (index = @catalog.index(target.table, schema: target.schema)) then [index]
288
+ elsif (table = @catalog.table(target.table, schema: target.schema))
289
+ @catalog.indexes.select { |i| i.schema == table.schema && Catalog.same_name?(i.table, table.name) }
290
+ else
291
+ []
292
+ end
293
+ indexes.each { |i| act(:reindex, name: i.name, schema: i.schema, node: statement) }
294
+ end
295
+
296
+ # ANALYZE names a table, an index, a schema, or nothing for every schema but temp
297
+ def analyze_tables(statement)
298
+ target = statement.target
299
+ return target && act(:analyze, name: target.table, schema: target.schema || "main", node: statement) unless @catalog
300
+
301
+ tables =
302
+ if target.nil? then @catalog.tables.reject { |t| Catalog.same_name?(t.schema, "temp") }
303
+ elsif (table = @catalog.table(target.table, schema: target.schema)) then [table]
304
+ elsif (index = @catalog.index(target.table, schema: target.schema)) then [@catalog.table(index.table, schema: index.schema)].compact
305
+ elsif target.schema.nil? then @catalog.tables.select { |t| Catalog.same_name?(t.schema, target.table) }
306
+ else
307
+ []
308
+ end
309
+ tables.each { |t| act(:analyze, name: t.name, schema: t.schema, node: statement) unless t.module_name }
310
+ end
311
+
312
+ # SQLite hands the authorizer a PRAGMA's value as text, unquoted and without a plus sign
313
+ def pragma_value(statement)
314
+ src = statement.value_tk_src or return
315
+ (src.match?(/\A['"]/) ? statement.value_tk_val.to_s : src.delete_prefix("+"))
316
+ end
317
+
318
+ # ATTACH and DETACH name a file or schema with any expression; a plain string or name is told
319
+ def plain_value(node)
320
+ case node
321
+ when LiteralExpression then node.value.to_s
322
+ when ColumnName then node.column
323
+ end
324
+ end
325
+
326
+ # What a trigger's WHEN and body read and write each time it fires. NEW and OLD stand for
327
+ # the row of the table it is on, and reading them reads that table, as SQLite's authorizer has it
328
+ def trigger(statement)
329
+ on_schema = @catalog&.table(statement.table.table, schema: statement.table.schema)&.schema
330
+ act(:create_trigger, temp: statement.trigger.schema || (statement.temporary ? "temp" : on_schema || "main"), name: statement.trigger.name, detail: statement.table.table, node: statement)
331
+ scope = Scope.new(sources: [], parent: nil, ctes: {}, aliases: {})
332
+ if (on = trigger_table(statement.table))
333
+ rows = [("new" unless statement.event == :DELETE), ("old" unless statement.event == :INSERT)].compact
334
+ rows.each { |name| scope.sources << on.dup.tap { |row| row.name, row.qualified_only = name, true } }
335
+ end
336
+
337
+ expression(statement.condition, scope, :referenced)
338
+ statement.statements.each do |body|
339
+ case body
340
+ when SelectStatement then select(body, parent: scope, usage: :projected)
341
+ when InsertStatement then insert(body, parent: scope)
342
+ when UpdateStatement then update(body, parent: scope)
343
+ when DeleteStatement then delete(body, parent: scope)
344
+ end
345
+ end
346
+ end
347
+
348
+ # The table or view a trigger is on, which it neither reads nor writes by being on it
349
+ def trigger_table(node)
350
+ scope = Scope.new(sources: [], parent: nil, ctes: {}, aliases: {})
351
+ add_target(node, scope)
352
+ end
353
+
354
+ # Assigned values end up in the table, so whatever they read counts as projected
355
+ def assign(assignments, target, scope)
356
+ assignments&.each do |assignment|
357
+ assignment.columns.each { |column| write(target, column.column, :update, column) }
358
+ expression(assignment.value, scope, :projected)
359
+ end
360
+ end
361
+
362
+ # A bare * in RETURNING means the target's columns only, not those of an UPDATE's FROM
363
+ def returning(clause, scope, target)
364
+ clause&.columns&.each do |column|
365
+ if StarResultColumn === column && column.table.nil?
366
+ star_source(target, :projected, column, skip_merged: false) if target
367
+ elsif StarResultColumn === column
368
+ star(column, scope, :projected)
369
+ else
370
+ expression(column.result, scope, :projected)
371
+ end
372
+ end
373
+ end
374
+
375
+ def tail(statement, scope)
376
+ statement.orderings&.each { |term| expression(term.expression, scope, :referenced) }
377
+ expression(statement.limit, scope, :referenced)
378
+ expression(statement.offset, scope, :referenced)
379
+ end
380
+
381
+ # The table an INSERT, UPDATE or DELETE writes to. CTEs never stand in for it, and
382
+ # writing to it is not a read, so it joins the scope without recording one
383
+ def add_target(node, scope)
384
+ name = node.alias || node.table
385
+ alias_of(node.alias, node.schema, node.table)
386
+ source =
387
+ if @catalog.nil?
388
+ Source.new(name:, kind: :table, schema: node.schema, node:)
389
+ elsif (table = @catalog.table(node.table, schema: node.schema))
390
+ Source.table(name, table, node)
391
+ elsif (view = @catalog.view(node.table, schema: node.schema))
392
+ # Writable only through INSTEAD OF triggers, which we do not look into
393
+ @reads.views << Analysis.table_key(view.schema, view.name)
394
+ Source.new(name:, kind: :derived, schema: view.schema, columns: view.columns, node:)
395
+ else
396
+ unresolved(node.table, "no such table", node)
397
+ return
398
+ end
399
+ scope.sources << source
400
+ source
401
+ end
402
+
403
+ def write(target, column, action, node)
404
+ return unless target
405
+
406
+ if target.table
407
+ if column && column != "*"
408
+ canonical = target.column(column)
409
+ # SQLite's authorizer names a write to the rowid "ROWID" however it was spelled, alias or not
410
+ canonical ||= "ROWID" if Catalog::ROWID_NAMES.include?(column.downcase(:ascii)) && target.table.rowid?
411
+ return unresolved(column, "no such column in #{target.table.name}", node) unless canonical
412
+
413
+ column = canonical
414
+ end
415
+ @writes.references << Reference.new(schema: target.table.schema, table: target.table.name, column:, usage: action, node:)
416
+ else
417
+ @writes.references << Reference.new(schema: target.schema, table: target.node.table, column:, usage: action, node:)
418
+ end
419
+ end
420
+
421
+ def select(statement, parent:, usage:)
422
+ scope = with_scope(statement.with_clause, parent)
423
+
424
+ names = nil
425
+ core_scope = nil
426
+ statement.cores.each do |core|
427
+ core_names, core_scope = select_core(core, parent: scope, usage:)
428
+ names ||= core_names
429
+ end
430
+
431
+ # ORDER BY of a compound can only name result columns, which reads nothing new
432
+ if statement.cores.size == 1 && core_scope
433
+ statement.orderings&.each do |term|
434
+ expression(term.expression, core_scope, :referenced, aliases: :first)
435
+ end
436
+ end
437
+ expression(statement.limit, scope, :referenced)
438
+ expression(statement.offset, scope, :referenced)
439
+
440
+ names
441
+ end
442
+
443
+ def select_core(core, parent:, usage:)
444
+ scope = Scope.new(sources: [], parent:, ctes: {}, aliases: {})
445
+
446
+ if ValuesClause === core
447
+ core.rows.each { |row| row.values.each { |v| expression(v, scope, usage) } }
448
+ width = core.rows.first.values.size
449
+ return [Array.new(width) { |i| "column#{i + 1}" }, scope]
450
+ end
451
+
452
+ add_from(core.source, scope, usage) if core.source
453
+
454
+ core.columns.each do |column|
455
+ scope.aliases[column.alias.downcase(:ascii)] = column.result if ResultColumn === column && column.alias
456
+ end
457
+
458
+ names = []
459
+ core.columns.each do |column|
460
+ if StarResultColumn === column
461
+ expanded = star(column, scope, usage)
462
+ expanded ? names.concat(expanded) : names << nil
463
+ else
464
+ expression(column.result, scope, usage)
465
+ names << output_name(column)
466
+ end
467
+ end
468
+
469
+ expression(core.condition, scope, :referenced, aliases: :fallback)
470
+ core.groupings&.each { |g| expression(g, scope, :referenced, aliases: :fallback) }
471
+ expression(core.having, scope, :referenced, aliases: :fallback)
472
+ core.windows&.each do |window|
473
+ window.partitions&.each { |p| expression(p, scope, :referenced) }
474
+ window.orderings&.each { |o| expression(o.expression, scope, :referenced) }
475
+ end
476
+
477
+ [names, scope]
478
+ end
479
+
480
+ def output_name(column)
481
+ return column.alias if column.alias
482
+
483
+ case (result = column.result)
484
+ when ColumnName then result.column
485
+ when Node then result.source_text
486
+ end
487
+ end
488
+
489
+ # -- FROM
490
+
491
+ def add_from(source, scope, usage)
492
+ add_source(source, scope, usage)
493
+ tables = scope.sources.sum { |s| join_tables(s) }
494
+ raise Error, "at most #{MAX_JOIN_TABLES} tables in a join" if tables > MAX_JOIN_TABLES
495
+ end
496
+
497
+ def join_tables(source) = (source.kind == :group) ? source.inner_sources.sum { |s| join_tables(s) } : 1
498
+
499
+ def add_source(source, scope, usage)
500
+ case source
501
+ when JoinClause
502
+ add_source(source.left, scope, usage)
503
+ source.joins.each do |join|
504
+ left_sources = scope.sources.dup
505
+ add_source(join.source, scope, usage)
506
+ join_constraint(join, left_sources, scope.sources.last, scope)
507
+ end
508
+ when TableName then add_table_source(source, scope, usage)
509
+ when TableFunction
510
+ alias_of(source.alias, source.schema, source.function)
511
+ source.arguments&.each { |a| expression(a, scope, :referenced) }
512
+ # Any virtual table can be called like this, `fts('query')` reads the fts table
513
+ if @catalog && (table = @catalog.table(source.function, schema: source.schema))
514
+ record(table.schema, table.name, nil, usage, source)
515
+ scope.sources << Source.table(source.alias || source.function, table, source)
516
+ return
517
+ end
518
+
519
+ @reads.functions << source.function.downcase(:ascii)
520
+ scope.sources << Source.new(
521
+ name: source.alias || source.function, kind: :function,
522
+ columns: FUNCTION_COLUMNS[source.function.downcase(:ascii)], node: source,
523
+ )
524
+ when SubquerySource
525
+ # A FROM subquery sees the enclosing queries, but not its siblings in this FROM
526
+ names = select(source.select, parent: scope.parent, usage:)
527
+ scope.sources << Source.new(name: source.alias, kind: :derived, columns: names, node: source)
528
+ when ParentheticalSource
529
+ if source.alias
530
+ inner = Scope.new(sources: [], parent: scope.parent, ctes: {})
531
+ add_source(source.source, inner, usage)
532
+ scope.sources << Source.new(name: source.alias, kind: :group, inner_sources: inner.sources, node: source)
533
+ else
534
+ add_source(source.source, scope, usage)
535
+ end
536
+ end
537
+ end
538
+
539
+ def add_table_source(source, scope, usage)
540
+ name = source.alias || source.table
541
+ alias_of(source.alias, source.schema, source.table)
542
+
543
+ if source.schema.nil? && (cte = find_cte(source.table, scope))
544
+ columns = analyze_cte(cte, usage)
545
+ scope.sources << Source.new(name:, kind: :derived, columns:, node: source)
546
+ elsif @catalog.nil?
547
+ record(source.schema, source.table, nil, usage, source)
548
+ scope.sources << Source.new(name:, kind: :table, schema: source.schema, table: nil, node: source)
549
+ elsif (table = @catalog.table(source.table, schema: source.schema))
550
+ record(table.schema, table.name, nil, usage, source)
551
+ scope.sources << Source.table(name, table, source)
552
+ elsif (view = @catalog.view(source.table, schema: source.schema))
553
+ @reads.views << Analysis.table_key(view.schema, view.name)
554
+ if @expanding_views.include?(view)
555
+ unresolved(source.table, "circularly defined view", source)
556
+ else
557
+ @expanding_views << view
558
+ # A view's body starts a scope chain of its own, so the CTEs of the query using it stay out of sight
559
+ names = select(view.select, parent: nil, usage:)
560
+ @expanding_views.delete(view)
561
+ end
562
+ scope.sources << Source.new(name:, kind: :derived, columns: view.columns || names, node: source)
563
+ else
564
+ unresolved(source.table, "no such table", source)
565
+ scope.sources << Source.new(name:, kind: :table, schema: source.schema, node: source)
566
+ end
567
+ end
568
+
569
+ def find_cte(name, scope)
570
+ needle = name.downcase(:ascii)
571
+ scope.each_up do |s|
572
+ return s.ctes[needle] if s.ctes&.key?(needle)
573
+ end
574
+ nil
575
+ end
576
+
577
+ def analyze_cte(cte, usage)
578
+ declared = cte.node.column_names
579
+ # A recursive CTE refers to itself, we know its columns at best from the declaration
580
+ return declared || cte.columns if cte.in_progress || cte.analyzed.include?(usage)
581
+
582
+ cte.in_progress = true
583
+ names = select(cte.node.select, parent: cte.scope, usage:)
584
+ cte.in_progress = false
585
+ cte.analyzed << usage
586
+ cte.columns = names
587
+ declared || names
588
+ end
589
+
590
+ def join_constraint(join, left_sources, right, scope)
591
+ shared =
592
+ case join.constraint
593
+ when JoinUsingConstraint then join.constraint.column_names
594
+ else
595
+ if join.operator.natural
596
+ if right.columns.nil? || left_sources.any? { |s| s.columns.nil? }
597
+ unresolved(right.name.to_s, "cannot work out the columns of a NATURAL JOIN without a catalog", join)
598
+ []
599
+ else
600
+ right.columns.select { |c| left_sources.any? { |s| s.column(c) } }
601
+ end
602
+ else
603
+ expression(join.constraint.expression, scope, :referenced) if JoinOnConstraint === join.constraint
604
+ []
605
+ end
606
+ end
607
+
608
+ # Both sides of USING and NATURAL are compared, even though SQLite's authorizer
609
+ # does not report the right-hand one
610
+ shared.each do |column|
611
+ left = left_sources.find { |s| s.column(column) } || left_sources.find { |s| s.columns.nil? }
612
+ read_through(left, column, :referenced, join) if left
613
+ read_through(right, column, :referenced, join)
614
+ (right.merged ||= Set.new) << column.downcase(:ascii)
615
+ end
616
+ end
617
+
618
+ def star(column, scope, usage)
619
+ sources =
620
+ if column.table
621
+ found = scope.sources.find { |s| s.matches?(column.table, nil) }
622
+ unless found
623
+ unresolved("#{column.table}.*", "no such table", column)
624
+ return nil
625
+ end
626
+ [found]
627
+ else
628
+ scope.sources
629
+ end
630
+
631
+ names = []
632
+ sources.each do |source|
633
+ expanded = star_source(source, usage, column, skip_merged: column.table.nil?)
634
+ expanded ? names.concat(expanded) : (return nil)
635
+ end
636
+ names
637
+ end
638
+
639
+ def star_source(source, usage, node, skip_merged:)
640
+ case source.kind
641
+ when :group
642
+ source.inner_sources.flat_map { |m| star_source(m, usage, node, skip_merged:) || [] }
643
+ when :table
644
+ if source.columns.nil?
645
+ record(source.schema, source.node.table, "*", usage, node)
646
+ nil
647
+ else
648
+ columns = source.columns.reject { |c| skip_merged && source.merged&.include?(c.downcase(:ascii)) }
649
+ columns.each { |c| record(source.table.schema, source.table.name, c, usage, node) }
650
+ columns
651
+ end
652
+ else
653
+ source.columns&.reject { |c| skip_merged && c && source.merged&.include?(c.downcase(:ascii)) }
654
+ end
655
+ end
656
+
657
+ # -- expressions
658
+
659
+ def expression(node, scope, usage, aliases: :none)
660
+ case node
661
+ when nil, Variable then nil
662
+ when LiteralExpression
663
+ @reads.calls << node.value.name.downcase if node.value in :CURRENT_DATE | :CURRENT_TIME | :CURRENT_TIMESTAMP
664
+ when ColumnName then column(node, scope, usage, aliases)
665
+ when FunctionReference
666
+ @reads.calls << node.function.downcase(:ascii)
667
+ node.children.each { |child| expression(child, scope, usage, aliases:) }
668
+ when SelectStatement then select(node, parent: scope, usage:)
669
+ when TableName then whole_table(node, scope)
670
+ when Node
671
+ if node.respond_to?(:operator) && (function = OPERATOR_FUNCTIONS[node.operator])
672
+ @reads.calls << function
673
+ end
674
+ node.children.each { |child| expression(child, scope, usage, aliases:) }
675
+ end
676
+ end
677
+
678
+ # `x IN t` compares against the single column of t
679
+ def whole_table(node, scope)
680
+ fake = Scope.new(sources: [], parent: scope, ctes: {})
681
+ add_table_source(node, fake, :referenced)
682
+ source = fake.sources.last
683
+ star_source(source, :referenced, node, skip_merged: false) if source
684
+ end
685
+
686
+ def column(node, scope, usage, aliases)
687
+ name = node.column
688
+
689
+ if node.table
690
+ scope.each_up do |s|
691
+ source = s.sources.find { |src| src.matches?(node.table, node.schema) }
692
+ return read_through(source, name, usage, node) if source
693
+ end
694
+ return unresolved(qualified_name(node), "no such table: #{node.table}", node)
695
+ end
696
+
697
+ return if aliases == :first && scope.aliases&.key?(name.downcase(:ascii))
698
+
699
+ scope.each_up do |s|
700
+ return if resolve_in(s, name, usage, node)
701
+ return if s.equal?(scope) && aliases == :fallback && s.aliases&.key?(name.downcase(:ascii))
702
+ return if rowid(s, name, usage, node)
703
+ end
704
+
705
+ # SQLite reads an unresolvable "double-quoted" identifier as a string literal
706
+ return if node.column_tk_src&.start_with?('"')
707
+
708
+ unresolved(name, "no such column", node)
709
+ end
710
+
711
+ def resolve_in(scope, name, usage, node)
712
+ candidates = scope.sources.reject(&:qualified_only)
713
+ known = candidates.select { |s| source_has?(s, name) }
714
+ unknown = candidates.select { |s| source_unknown?(s) }
715
+
716
+ if known.size == 1 && unknown.empty?
717
+ read_through(known.first, name, usage, node)
718
+ elsif known.size > 1 && unknown.empty?
719
+ # A USING/NATURAL column belongs to both sides and is not ambiguous
720
+ merged = known.select { |s| s.merged&.include?(name.downcase(:ascii)) }
721
+ if merged.any?
722
+ read_through((known - merged).first || known.first, name, usage, node)
723
+ else
724
+ unresolved(name, "ambiguous column name", node)
725
+ end
726
+ elsif known.empty? && unknown.size == 1
727
+ read_through(unknown.first, name, usage, node)
728
+ elsif unknown.any? && (known.size + unknown.size) > 1
729
+ unresolved(name, "ambiguous column name without a catalog", node)
730
+ else
731
+ return false
732
+ end
733
+ true
734
+ end
735
+
736
+ def source_has?(source, name)
737
+ return source.inner_sources.any? { |m| source_has?(m, name) } if source.kind == :group
738
+
739
+ !!source.column(name)
740
+ end
741
+
742
+ def source_unknown?(source)
743
+ return source.inner_sources.any? { |m| source_unknown?(m) } if source.kind == :group
744
+
745
+ source.columns.nil?
746
+ end
747
+
748
+ def rowid(scope, name, usage, node)
749
+ return false unless Catalog::ROWID_NAMES.include?(name.downcase(:ascii))
750
+
751
+ candidates = scope.sources.select { |s| s.kind == :table && (s.table.nil? || s.table.rowid?) }
752
+ return false unless candidates.size == 1
753
+
754
+ read_through(candidates.first, name, usage, node)
755
+ true
756
+ end
757
+
758
+ def read_through(source, name, usage, node)
759
+ case source.kind
760
+ when :table
761
+ if source.table
762
+ column = source.table.column(name)
763
+ column ||= source.table.rowid_alias || "rowid" if Catalog::ROWID_NAMES.include?(name.downcase(:ascii)) && source.table.rowid?
764
+ return unresolved(name, "no such column in #{source.table.name}", node) unless column
765
+
766
+ record(source.table.schema, source.table.name, column, usage, node)
767
+ else
768
+ record(source.schema, source.node.table, name, usage, node)
769
+ end
770
+ when :group
771
+ member = source.inner_sources.find { |m| source_has?(m, name) } || source.inner_sources.find { |m| source_unknown?(m) }
772
+ member ? read_through(member, name, usage, node) : unresolved(name, "no such column", node)
773
+ when :derived, :function
774
+ if source.columns && !source.column(name) && !source.columns.include?(nil)
775
+ unresolved(name, "no such column in #{source.name}", node)
776
+ end
777
+ end
778
+ true
779
+ end
780
+
781
+ def alias_of(name, schema, table)
782
+ @reads.aliases[name] = Analysis.table_key(schema, table) if name && @expanding_views.empty?
783
+ end
784
+
785
+ def record(schema, table, column, usage, node)
786
+ @reads.references << Reference.new(schema:, table:, column:, usage:, node:)
787
+ end
788
+
789
+ def unresolved(name, reason, node)
790
+ @reads.unresolved << Unresolved.new(name:, reason:, node:)
791
+ true
792
+ end
793
+
794
+ def qualified_name(node) = [node.schema, node.table, node.column].compact.join(".")
795
+ end
796
+ end
797
+ end