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.
- checksums.yaml +4 -4
- data/README.md +280 -10
- data/lib/plume/analysis/resolver.rb +574 -0
- data/lib/plume/analysis.rb +135 -0
- data/lib/plume/ast/alter_table_statement.rb +29 -0
- data/lib/plume/ast/analyze_statement.rb +15 -0
- data/lib/plume/ast/assignment.rb +18 -0
- data/lib/plume/ast/attach_statement.rb +17 -0
- data/lib/plume/ast/begin_statement.rb +17 -0
- data/lib/plume/ast/binary_expression.rb +71 -0
- data/lib/plume/ast/case_condition.rb +29 -0
- data/lib/plume/ast/case_expression.rb +22 -0
- data/lib/plume/ast/cast_expression.rb +16 -0
- data/lib/plume/ast/check_column_constraint.rb +20 -0
- data/lib/plume/ast/check_table_constraint.rb +19 -0
- data/lib/plume/ast/collate_column_constraint.rb +19 -0
- data/lib/plume/ast/collation_expression.rb +26 -0
- data/lib/plume/ast/column_constraint.rb +7 -0
- data/lib/plume/ast/column_definition.rb +16 -0
- data/lib/plume/ast/column_name.rb +22 -0
- data/lib/plume/ast/column_type.rb +25 -0
- data/lib/plume/ast/commit_statement.rb +13 -0
- data/lib/plume/ast/common_table_expression.rb +31 -0
- data/lib/plume/ast/compound_select.rb +30 -0
- data/lib/plume/ast/conflict_clause.rb +22 -0
- data/lib/plume/ast/conflict_resolution.rb +20 -0
- data/lib/plume/ast/create_index_statement.rb +29 -0
- data/lib/plume/ast/create_table_statement.rb +47 -0
- data/lib/plume/ast/create_trigger_statement.rb +43 -0
- data/lib/plume/ast/create_view_statement.rb +30 -0
- data/lib/plume/ast/create_virtual_table_statement.rb +28 -0
- data/lib/plume/ast/default_column_constraint.rb +29 -0
- data/lib/plume/ast/delete_statement.rb +28 -0
- data/lib/plume/ast/detach_statement.rb +13 -0
- data/lib/plume/ast/drop_statement.rb +21 -0
- data/lib/plume/ast/empty_function_argument.rb +6 -0
- data/lib/plume/ast/exists_expression.rb +15 -0
- data/lib/plume/ast/explain_statement.rb +15 -0
- data/lib/plume/ast/expression.rb +30 -0
- data/lib/plume/ast/foreign_key_action.rb +26 -0
- data/lib/plume/ast/foreign_key_clause.rb +50 -0
- data/lib/plume/ast/foreign_key_column_constraint.rb +16 -0
- data/lib/plume/ast/foreign_key_table_constraint.rb +20 -0
- data/lib/plume/ast/frame_boundary.rb +35 -0
- data/lib/plume/ast/frame_spec.rb +34 -0
- data/lib/plume/ast/function_arguments.rb +17 -0
- data/lib/plume/ast/function_reference.rb +29 -0
- data/lib/plume/ast/generated_as_column_constraint.rb +24 -0
- data/lib/plume/ast/indexed_column.rb +21 -0
- data/lib/plume/ast/insert_statement.rb +35 -0
- data/lib/plume/ast/join.rb +12 -0
- data/lib/plume/ast/join_clause.rb +15 -0
- data/lib/plume/ast/join_on_constraint.rb +12 -0
- data/lib/plume/ast/join_operator.rb +31 -0
- data/lib/plume/ast/join_using_constraint.rb +16 -0
- data/lib/plume/ast/like_expression.rb +19 -0
- data/lib/plume/ast/literal_expression.rb +29 -0
- data/lib/plume/ast/literal_false.rb +7 -0
- data/lib/plume/ast/literal_nil.rb +7 -0
- data/lib/plume/ast/literal_value.rb +18 -0
- data/lib/plume/ast/match_clause.rb +15 -0
- data/lib/plume/ast/node.rb +207 -0
- data/lib/plume/ast/noop_column_constraint.rb +15 -0
- data/lib/plume/ast/noop_table_constraint.rb +15 -0
- data/lib/plume/ast/not_like_expression.rb +7 -0
- data/lib/plume/ast/not_null_column_constraint.rb +17 -0
- data/lib/plume/ast/null_column_constraint.rb +17 -0
- data/lib/plume/ast/ordering_term.rb +32 -0
- data/lib/plume/ast/over_clause.rb +25 -0
- data/lib/plume/ast/parenthetical_expression.rb +37 -0
- data/lib/plume/ast/parenthetical_source.rb +19 -0
- data/lib/plume/ast/pragma_statement.rb +26 -0
- data/lib/plume/ast/primary_key_column_constraint.rb +23 -0
- data/lib/plume/ast/primary_key_table_constraint.rb +23 -0
- data/lib/plume/ast/raise_expression.rb +23 -0
- data/lib/plume/ast/reindex_statement.rb +15 -0
- data/lib/plume/ast/release_statement.rb +16 -0
- data/lib/plume/ast/result_column.rb +18 -0
- data/lib/plume/ast/returning_clause.rb +12 -0
- data/lib/plume/ast/rollback_statement.rb +19 -0
- data/lib/plume/ast/savepoint_statement.rb +16 -0
- data/lib/plume/ast/select_core.rb +27 -0
- data/lib/plume/ast/select_statement.rb +34 -0
- data/lib/plume/ast/star_function_argument.rb +6 -0
- data/lib/plume/ast/star_result_column.rb +16 -0
- data/lib/plume/ast/strict_table_option.rb +6 -0
- data/lib/plume/ast/subquery_expression.rb +14 -0
- data/lib/plume/ast/subquery_source.rb +19 -0
- data/lib/plume/ast/table_constraint.rb +7 -0
- data/lib/plume/ast/table_function.rb +28 -0
- data/lib/plume/ast/table_name.rb +28 -0
- data/lib/plume/ast/table_option.rb +7 -0
- data/lib/plume/ast/ternary_expression.rb +26 -0
- data/lib/plume/ast/token.rb +180 -0
- data/lib/plume/ast/unary_expression.rb +36 -0
- data/lib/plume/ast/unique_column_constraint.rb +17 -0
- data/lib/plume/ast/unique_table_constraint.rb +20 -0
- data/lib/plume/ast/update_statement.rb +35 -0
- data/lib/plume/ast/upsert_clause.rb +26 -0
- data/lib/plume/ast/vacuum_statement.rb +18 -0
- data/lib/plume/ast/values_clause.rb +12 -0
- data/lib/plume/ast/values_row.rb +13 -0
- data/lib/plume/ast/variable.rb +15 -0
- data/lib/plume/ast/window_definition.rb +26 -0
- data/lib/plume/ast/with_clause.rb +15 -0
- data/lib/plume/ast/without_rowid_table_option.rb +6 -0
- data/lib/plume/catalog.rb +180 -0
- data/lib/plume/lexer.rb +639 -0
- data/lib/plume/parser.rb +4015 -0
- data/lib/plume/printer.rb +471 -0
- data/lib/plume/sqlite_grammar.rb +86 -0
- data/lib/plume/union.rb +28 -0
- data/lib/plume/version.rb +1 -1
- data/lib/plume.rb +143 -3
- 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
|