plume 0.2.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.
- checksums.yaml +4 -4
- data/README.md +244 -19
- data/lib/plume/analysis/resolver.rb +255 -32
- data/lib/plume/analysis.rb +93 -17
- data/lib/plume/ast/alter_table_statement.rb +14 -3
- data/lib/plume/ast/check_table_constraint.rb +1 -0
- data/lib/plume/ast/collate_column_constraint.rb +1 -1
- data/lib/plume/ast/collation_expression.rb +1 -1
- data/lib/plume/ast/column_type.rb +2 -1
- data/lib/plume/ast/deferrable_column_constraint.rb +20 -0
- data/lib/plume/ast/function_arguments.rb +2 -1
- data/lib/plume/ast/indexed_column.rb +4 -1
- data/lib/plume/ast/node.rb +135 -12
- data/lib/plume/ast/ordering_term.rb +1 -1
- data/lib/plume/ast/token.rb +7 -4
- data/lib/plume/catalog.rb +149 -53
- data/lib/plume/comment.rb +73 -0
- data/lib/plume/dump.rb +143 -0
- data/lib/plume/fingerprint.rb +86 -0
- data/lib/plume/lexer.rb +8 -7
- data/lib/plume/literal_patches.rb +76 -0
- data/lib/plume/normalize.rb +65 -0
- data/lib/plume/parameters.rb +52 -0
- data/lib/plume/parser.rb +221 -172
- data/lib/plume/pretty_printer.rb +202 -0
- data/lib/plume/printer.rb +71 -17
- data/lib/plume/truncate.rb +67 -0
- data/lib/plume/version.rb +1 -1
- data/lib/plume.rb +134 -3
- metadata +11 -2
|
@@ -19,18 +19,18 @@ module Plume
|
|
|
19
19
|
def self.table(name, table, node) = new(name:, kind: :table, schema: table.schema, table:, columns: table.columns, node:)
|
|
20
20
|
|
|
21
21
|
def matches?(qualifier, schema_qualifier)
|
|
22
|
-
return false unless name&.casecmp
|
|
22
|
+
return false unless name&.casecmp(qualifier)&.zero?
|
|
23
23
|
return true unless schema_qualifier
|
|
24
24
|
|
|
25
|
-
schema&.casecmp
|
|
25
|
+
schema&.casecmp(schema_qualifier)&.zero? || (schema.nil? && schema_qualifier.casecmp("main")&.zero?)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def column(name)
|
|
29
29
|
return unless columns
|
|
30
30
|
return table.column(name) if table
|
|
31
31
|
|
|
32
|
-
needle = name.downcase
|
|
33
|
-
columns.find { |c| c&.downcase == needle }
|
|
32
|
+
needle = name.downcase(:ascii)
|
|
33
|
+
columns.find { |c| c&.downcase(:ascii) == needle }
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -53,10 +53,19 @@ module Plume
|
|
|
53
53
|
"json_tree" => JSON_TABLE_COLUMNS,
|
|
54
54
|
"jsonb_each" => JSON_TABLE_COLUMNS,
|
|
55
55
|
"jsonb_tree" => JSON_TABLE_COLUMNS,
|
|
56
|
-
"generate_series" => %w[value start stop step],
|
|
56
|
+
"generate_series" => %w[value start stop step].freeze,
|
|
57
57
|
}.freeze
|
|
58
58
|
|
|
59
|
-
STATEMENTS = [SelectStatement, InsertStatement, UpdateStatement, DeleteStatement].freeze
|
|
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
|
|
60
69
|
|
|
61
70
|
def initialize(catalog: nil)
|
|
62
71
|
@catalog = catalog
|
|
@@ -70,9 +79,12 @@ module Plume
|
|
|
70
79
|
when InsertStatement then insert(statement)
|
|
71
80
|
when UpdateStatement then update(statement)
|
|
72
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)
|
|
73
85
|
end
|
|
74
86
|
|
|
75
|
-
Access.new(reads: @reads, writes: @writes)
|
|
87
|
+
Access.new(reads: @reads, writes: @writes, actions: @actions.freeze)
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
# Names of the columns a select produces, nil for the ones we cannot name
|
|
@@ -85,7 +97,8 @@ module Plume
|
|
|
85
97
|
|
|
86
98
|
def start(statement)
|
|
87
99
|
@reads = Reads.new(statement)
|
|
88
|
-
@writes = Writes.new(statement, @reads.unresolved)
|
|
100
|
+
@writes = Writes.new(statement, @reads.unresolved, @reads.ctes, @reads.aliases)
|
|
101
|
+
@actions = []
|
|
89
102
|
# By identity: a view holds its parsed select, and hashing that hashes the whole tree
|
|
90
103
|
@expanding_views = Set.new.compare_by_identity
|
|
91
104
|
end
|
|
@@ -93,15 +106,16 @@ module Plume
|
|
|
93
106
|
def with_scope(with_clause, parent)
|
|
94
107
|
scope = Scope.new(sources: [], parent:, ctes: {})
|
|
95
108
|
with_clause&.ctes&.each do |cte|
|
|
96
|
-
scope.ctes[cte.name.downcase] = Cte.new(node: cte, scope:, analyzed: Set.new)
|
|
109
|
+
scope.ctes[cte.name.downcase(:ascii)] = Cte.new(node: cte, scope:, analyzed: Set.new)
|
|
110
|
+
@reads.ctes << cte.name if @expanding_views.empty?
|
|
97
111
|
end
|
|
98
112
|
scope
|
|
99
113
|
end
|
|
100
114
|
|
|
101
115
|
# -- INSERT, UPDATE, DELETE
|
|
102
116
|
|
|
103
|
-
def insert(statement)
|
|
104
|
-
outer = with_scope(statement.with_clause,
|
|
117
|
+
def insert(statement, parent: nil)
|
|
118
|
+
outer = with_scope(statement.with_clause, parent)
|
|
105
119
|
# The rows being inserted cannot see the target table, upserts and RETURNING can
|
|
106
120
|
select(statement.select, parent: outer, usage: :projected) if statement.select
|
|
107
121
|
|
|
@@ -113,7 +127,8 @@ module Plume
|
|
|
113
127
|
elsif statement.columns
|
|
114
128
|
statement.columns.each { |column| write(target, column.column, :insert, column) }
|
|
115
129
|
elsif target&.columns
|
|
116
|
-
|
|
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) }
|
|
117
132
|
else
|
|
118
133
|
write(target, "*", :insert, statement.table)
|
|
119
134
|
end
|
|
@@ -131,11 +146,15 @@ module Plume
|
|
|
131
146
|
returning(statement.returning, scope, target)
|
|
132
147
|
end
|
|
133
148
|
|
|
134
|
-
def update(statement)
|
|
135
|
-
outer = with_scope(statement.with_clause,
|
|
149
|
+
def update(statement, parent: nil)
|
|
150
|
+
outer = with_scope(statement.with_clause, parent)
|
|
136
151
|
scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
|
|
137
152
|
target = add_target(statement.table, scope)
|
|
138
|
-
|
|
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
|
|
139
158
|
|
|
140
159
|
assign(statement.assignments, target, scope)
|
|
141
160
|
expression(statement.condition, scope, :referenced)
|
|
@@ -143,8 +162,8 @@ module Plume
|
|
|
143
162
|
tail(statement, scope)
|
|
144
163
|
end
|
|
145
164
|
|
|
146
|
-
def delete(statement)
|
|
147
|
-
outer = with_scope(statement.with_clause,
|
|
165
|
+
def delete(statement, parent: nil)
|
|
166
|
+
outer = with_scope(statement.with_clause, parent)
|
|
148
167
|
scope = Scope.new(sources: [], parent: outer, ctes: {}, aliases: {})
|
|
149
168
|
target = add_target(statement.table, scope)
|
|
150
169
|
|
|
@@ -154,6 +173,184 @@ module Plume
|
|
|
154
173
|
tail(statement, scope)
|
|
155
174
|
end
|
|
156
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
|
+
|
|
157
354
|
# Assigned values end up in the table, so whatever they read counts as projected
|
|
158
355
|
def assign(assignments, target, scope)
|
|
159
356
|
assignments&.each do |assignment|
|
|
@@ -185,6 +382,7 @@ module Plume
|
|
|
185
382
|
# writing to it is not a read, so it joins the scope without recording one
|
|
186
383
|
def add_target(node, scope)
|
|
187
384
|
name = node.alias || node.table
|
|
385
|
+
alias_of(node.alias, node.schema, node.table)
|
|
188
386
|
source =
|
|
189
387
|
if @catalog.nil?
|
|
190
388
|
Source.new(name:, kind: :table, schema: node.schema, node:)
|
|
@@ -208,6 +406,8 @@ module Plume
|
|
|
208
406
|
if target.table
|
|
209
407
|
if column && column != "*"
|
|
210
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?
|
|
211
411
|
return unresolved(column, "no such column in #{target.table.name}", node) unless canonical
|
|
212
412
|
|
|
213
413
|
column = canonical
|
|
@@ -249,10 +449,10 @@ module Plume
|
|
|
249
449
|
return [Array.new(width) { |i| "column#{i + 1}" }, scope]
|
|
250
450
|
end
|
|
251
451
|
|
|
252
|
-
|
|
452
|
+
add_from(core.source, scope, usage) if core.source
|
|
253
453
|
|
|
254
454
|
core.columns.each do |column|
|
|
255
|
-
scope.aliases[column.alias.downcase] = column.result if ResultColumn === column && column.alias
|
|
455
|
+
scope.aliases[column.alias.downcase(:ascii)] = column.result if ResultColumn === column && column.alias
|
|
256
456
|
end
|
|
257
457
|
|
|
258
458
|
names = []
|
|
@@ -288,6 +488,14 @@ module Plume
|
|
|
288
488
|
|
|
289
489
|
# -- FROM
|
|
290
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
|
+
|
|
291
499
|
def add_source(source, scope, usage)
|
|
292
500
|
case source
|
|
293
501
|
when JoinClause
|
|
@@ -299,6 +507,7 @@ module Plume
|
|
|
299
507
|
end
|
|
300
508
|
when TableName then add_table_source(source, scope, usage)
|
|
301
509
|
when TableFunction
|
|
510
|
+
alias_of(source.alias, source.schema, source.function)
|
|
302
511
|
source.arguments&.each { |a| expression(a, scope, :referenced) }
|
|
303
512
|
# Any virtual table can be called like this, `fts('query')` reads the fts table
|
|
304
513
|
if @catalog && (table = @catalog.table(source.function, schema: source.schema))
|
|
@@ -307,10 +516,10 @@ module Plume
|
|
|
307
516
|
return
|
|
308
517
|
end
|
|
309
518
|
|
|
310
|
-
@reads.functions << source.function.downcase
|
|
519
|
+
@reads.functions << source.function.downcase(:ascii)
|
|
311
520
|
scope.sources << Source.new(
|
|
312
521
|
name: source.alias || source.function, kind: :function,
|
|
313
|
-
columns: FUNCTION_COLUMNS[source.function.downcase], node: source,
|
|
522
|
+
columns: FUNCTION_COLUMNS[source.function.downcase(:ascii)], node: source,
|
|
314
523
|
)
|
|
315
524
|
when SubquerySource
|
|
316
525
|
# A FROM subquery sees the enclosing queries, but not its siblings in this FROM
|
|
@@ -329,6 +538,7 @@ module Plume
|
|
|
329
538
|
|
|
330
539
|
def add_table_source(source, scope, usage)
|
|
331
540
|
name = source.alias || source.table
|
|
541
|
+
alias_of(source.alias, source.schema, source.table)
|
|
332
542
|
|
|
333
543
|
if source.schema.nil? && (cte = find_cte(source.table, scope))
|
|
334
544
|
columns = analyze_cte(cte, usage)
|
|
@@ -357,7 +567,7 @@ module Plume
|
|
|
357
567
|
end
|
|
358
568
|
|
|
359
569
|
def find_cte(name, scope)
|
|
360
|
-
needle = name.downcase
|
|
570
|
+
needle = name.downcase(:ascii)
|
|
361
571
|
scope.each_up do |s|
|
|
362
572
|
return s.ctes[needle] if s.ctes&.key?(needle)
|
|
363
573
|
end
|
|
@@ -401,7 +611,7 @@ module Plume
|
|
|
401
611
|
left = left_sources.find { |s| s.column(column) } || left_sources.find { |s| s.columns.nil? }
|
|
402
612
|
read_through(left, column, :referenced, join) if left
|
|
403
613
|
read_through(right, column, :referenced, join)
|
|
404
|
-
(right.merged ||= Set.new) << column.downcase
|
|
614
|
+
(right.merged ||= Set.new) << column.downcase(:ascii)
|
|
405
615
|
end
|
|
406
616
|
end
|
|
407
617
|
|
|
@@ -435,12 +645,12 @@ module Plume
|
|
|
435
645
|
record(source.schema, source.node.table, "*", usage, node)
|
|
436
646
|
nil
|
|
437
647
|
else
|
|
438
|
-
columns = source.columns.reject { |c| skip_merged && source.merged&.include?(c.downcase) }
|
|
648
|
+
columns = source.columns.reject { |c| skip_merged && source.merged&.include?(c.downcase(:ascii)) }
|
|
439
649
|
columns.each { |c| record(source.table.schema, source.table.name, c, usage, node) }
|
|
440
650
|
columns
|
|
441
651
|
end
|
|
442
652
|
else
|
|
443
|
-
source.columns&.reject { |c| skip_merged && c && source.merged&.include?(c.downcase) }
|
|
653
|
+
source.columns&.reject { |c| skip_merged && c && source.merged&.include?(c.downcase(:ascii)) }
|
|
444
654
|
end
|
|
445
655
|
end
|
|
446
656
|
|
|
@@ -448,11 +658,20 @@ module Plume
|
|
|
448
658
|
|
|
449
659
|
def expression(node, scope, usage, aliases: :none)
|
|
450
660
|
case node
|
|
451
|
-
when nil, Variable
|
|
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
|
|
452
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:) }
|
|
453
668
|
when SelectStatement then select(node, parent: scope, usage:)
|
|
454
669
|
when TableName then whole_table(node, scope)
|
|
455
|
-
when Node
|
|
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:) }
|
|
456
675
|
end
|
|
457
676
|
end
|
|
458
677
|
|
|
@@ -475,11 +694,11 @@ module Plume
|
|
|
475
694
|
return unresolved(qualified_name(node), "no such table: #{node.table}", node)
|
|
476
695
|
end
|
|
477
696
|
|
|
478
|
-
return if aliases == :first && scope.aliases&.key?(name.downcase)
|
|
697
|
+
return if aliases == :first && scope.aliases&.key?(name.downcase(:ascii))
|
|
479
698
|
|
|
480
699
|
scope.each_up do |s|
|
|
481
700
|
return if resolve_in(s, name, usage, node)
|
|
482
|
-
return if s.equal?(scope) && aliases == :fallback && s.aliases&.key?(name.downcase)
|
|
701
|
+
return if s.equal?(scope) && aliases == :fallback && s.aliases&.key?(name.downcase(:ascii))
|
|
483
702
|
return if rowid(s, name, usage, node)
|
|
484
703
|
end
|
|
485
704
|
|
|
@@ -498,7 +717,7 @@ module Plume
|
|
|
498
717
|
read_through(known.first, name, usage, node)
|
|
499
718
|
elsif known.size > 1 && unknown.empty?
|
|
500
719
|
# A USING/NATURAL column belongs to both sides and is not ambiguous
|
|
501
|
-
merged = known.select { |s| s.merged&.include?(name.downcase) }
|
|
720
|
+
merged = known.select { |s| s.merged&.include?(name.downcase(:ascii)) }
|
|
502
721
|
if merged.any?
|
|
503
722
|
read_through((known - merged).first || known.first, name, usage, node)
|
|
504
723
|
else
|
|
@@ -527,7 +746,7 @@ module Plume
|
|
|
527
746
|
end
|
|
528
747
|
|
|
529
748
|
def rowid(scope, name, usage, node)
|
|
530
|
-
return false unless Catalog::ROWID_NAMES.include?(name.downcase)
|
|
749
|
+
return false unless Catalog::ROWID_NAMES.include?(name.downcase(:ascii))
|
|
531
750
|
|
|
532
751
|
candidates = scope.sources.select { |s| s.kind == :table && (s.table.nil? || s.table.rowid?) }
|
|
533
752
|
return false unless candidates.size == 1
|
|
@@ -541,7 +760,7 @@ module Plume
|
|
|
541
760
|
when :table
|
|
542
761
|
if source.table
|
|
543
762
|
column = source.table.column(name)
|
|
544
|
-
column ||= source.table.rowid_alias || "rowid" if Catalog::ROWID_NAMES.include?(name.downcase) && source.table.rowid?
|
|
763
|
+
column ||= source.table.rowid_alias || "rowid" if Catalog::ROWID_NAMES.include?(name.downcase(:ascii)) && source.table.rowid?
|
|
545
764
|
return unresolved(name, "no such column in #{source.table.name}", node) unless column
|
|
546
765
|
|
|
547
766
|
record(source.table.schema, source.table.name, column, usage, node)
|
|
@@ -559,6 +778,10 @@ module Plume
|
|
|
559
778
|
true
|
|
560
779
|
end
|
|
561
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
|
+
|
|
562
785
|
def record(schema, table, column, usage, node)
|
|
563
786
|
@reads.references << Reference.new(schema:, table:, column:, usage:, node:)
|
|
564
787
|
end
|
data/lib/plume/analysis.rb
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Plume
|
|
4
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
|
|
5
|
+
# columns would this statement read?", see `Plume.reads`; the second is what it would
|
|
6
|
+
# write, see `Plume.writes`.
|
|
6
7
|
module Analysis
|
|
7
8
|
autoload :Resolver, "plume/analysis/resolver"
|
|
8
9
|
|
|
@@ -42,16 +43,21 @@ module Plume
|
|
|
42
43
|
# Tables in the main schema are keyed by their bare name, any other schema
|
|
43
44
|
# (temp, attached databases) is spelled out as "schema.table"
|
|
44
45
|
def self.table_key(schema, table)
|
|
45
|
-
(schema.nil? || schema.casecmp
|
|
46
|
+
(schema.nil? || schema.casecmp("main")&.zero?) ? table : "#{schema}.#{table}"
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
class References
|
|
49
|
-
|
|
50
|
+
# `ctes` are the names the statement's WITH clauses define, `aliases` maps each table alias
|
|
51
|
+
# to the table, view, CTE or function it stands for - all spelled as written, and none from
|
|
52
|
+
# inside the views read. Reads and Writes of one statement share them
|
|
53
|
+
attr_reader :statement, :references, :unresolved, :ctes, :aliases
|
|
50
54
|
|
|
51
|
-
def initialize(statement, unresolved = [])
|
|
55
|
+
def initialize(statement, unresolved = [], ctes = Set.new, aliases = {})
|
|
52
56
|
@statement = statement
|
|
53
57
|
@references = []
|
|
54
58
|
@unresolved = unresolved
|
|
59
|
+
@ctes = ctes
|
|
60
|
+
@aliases = aliases
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
# Every base table touched, including ones touched only as a whole
|
|
@@ -74,12 +80,16 @@ module Plume
|
|
|
74
80
|
end
|
|
75
81
|
|
|
76
82
|
class Reads < References
|
|
77
|
-
|
|
83
|
+
# `functions` are the table-valued ones in FROM, `calls` every other function called -
|
|
84
|
+
# including those LIKE, GLOB, REGEXP, MATCH, `->`, `->>` and CURRENT_* call - as SQLite's
|
|
85
|
+
# authorizer reports them
|
|
86
|
+
attr_reader :views, :functions, :calls
|
|
78
87
|
|
|
79
88
|
def initialize(...)
|
|
80
89
|
super
|
|
81
90
|
@views = Set.new
|
|
82
91
|
@functions = Set.new
|
|
92
|
+
@calls = Set.new
|
|
83
93
|
end
|
|
84
94
|
|
|
85
95
|
def projected = columns(:projected)
|
|
@@ -93,7 +103,47 @@ module Plume
|
|
|
93
103
|
def deleted = tables(:delete)
|
|
94
104
|
end
|
|
95
105
|
|
|
96
|
-
|
|
106
|
+
# What a run of statements reads, writes and does, as `Plume.summary` gives it. `accesses` has one
|
|
107
|
+
# entry per statement, the rest is merged across them
|
|
108
|
+
class Summary
|
|
109
|
+
attr_reader :statements, :accesses
|
|
110
|
+
|
|
111
|
+
def initialize(statements, accesses)
|
|
112
|
+
@statements = statements
|
|
113
|
+
@accesses = accesses
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Each statement's node class, like "SelectStatement" or "CreateTableStatement"
|
|
117
|
+
def statement_types = @statements.map { |s| s.class.name.delete_prefix("Plume::") }
|
|
118
|
+
|
|
119
|
+
# Every base table read or written, or only those touched in one way: `:projected`,
|
|
120
|
+
# `:referenced`, `:insert`, `:update` or `:delete`
|
|
121
|
+
def tables(usage = nil) = each_side.reduce(Set.new) { |all, refs| all | refs.tables(usage) }
|
|
122
|
+
|
|
123
|
+
def reads = @accesses.map(&:reads)
|
|
124
|
+
def writes = @accesses.map(&:writes)
|
|
125
|
+
def actions = @accesses.flat_map(&:actions)
|
|
126
|
+
def views = union(reads, :views)
|
|
127
|
+
def functions = union(reads, :functions)
|
|
128
|
+
def calls = union(reads, :calls)
|
|
129
|
+
def ctes = union(reads, :ctes)
|
|
130
|
+
def aliases = reads.reduce({}) { |all, r| all.merge(r.aliases) }
|
|
131
|
+
def unresolved = reads.flat_map(&:unresolved)
|
|
132
|
+
def resolved? = unresolved.empty?
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def each_side = reads + writes
|
|
137
|
+
def union(list, name) = list.reduce(Set.new) { |all, r| all | r.public_send(name) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Something a statement does besides reading and writing rows, named as SQLite's authorizer
|
|
141
|
+
# names it: `action` is its action code as a symbol (`:create_table`, `:drop_temp_index`,
|
|
142
|
+
# `:alter_table`, `:pragma`, `:transaction`…), and `name`, `detail` and `schema` are the
|
|
143
|
+
# arguments the authorizer gets with it - for `:create_index`, the index, its table and the schema
|
|
144
|
+
Action = Data.define(:action, :name, :detail, :schema, :node)
|
|
145
|
+
|
|
146
|
+
Access = Data.define(:reads, :writes, :actions) do
|
|
97
147
|
def unresolved = reads.unresolved
|
|
98
148
|
def resolved? = reads.resolved?
|
|
99
149
|
end
|
|
@@ -111,25 +161,51 @@ module Plume
|
|
|
111
161
|
# `Plume::Analysis::Error`; with `strict: false` it is listed in `Reads#unresolved` instead.
|
|
112
162
|
def self.reads(sql, catalog: nil, strict: true) = access(sql, catalog:, strict:).reads
|
|
113
163
|
|
|
114
|
-
#
|
|
164
|
+
# Which tables and columns an INSERT, UPDATE or DELETE would write, with the same arguments
|
|
165
|
+
# and strictness as `reads`. A SELECT writes nothing. The triggers a statement fires are not looked into, so rows
|
|
166
|
+
# they write elsewhere - or the rows a REPLACE deletes - are not reported.
|
|
167
|
+
def self.writes(sql, catalog: nil, strict: true) = access(sql, catalog:, strict:).writes
|
|
168
|
+
|
|
169
|
+
# Like `reads`, but also reports what an INSERT, UPDATE or DELETE writes, and in `actions` what
|
|
170
|
+
# any other statement does - creating, dropping and altering things, pragmas, transactions. A
|
|
171
|
+
# CREATE TRIGGER is analyzed for what its WHEN and body do each time it fires, a CREATE TABLE … AS
|
|
172
|
+
# reads what its SELECT reads, a CREATE INDEX reads what it indexes, DROP TABLE deletes the rows
|
|
115
173
|
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
|
|
174
|
+
statements = statements(sql)
|
|
122
175
|
raise Error, "expected exactly one statement, got #{statements.size}" unless statements.size == 1
|
|
123
176
|
|
|
124
177
|
statement = statements.first
|
|
125
|
-
|
|
126
|
-
|
|
178
|
+
access = begin
|
|
179
|
+
Resolver.new(catalog:).analyze(statement)
|
|
180
|
+
rescue *STACK_EXHAUSTED
|
|
181
|
+
# As with parsing, how deep the resolver can go depends on the stack the caller has left
|
|
182
|
+
raise Error, "statement nested too deeply"
|
|
127
183
|
end
|
|
128
|
-
|
|
129
|
-
access = Resolver.new(catalog:).analyze(statement)
|
|
130
184
|
raise Error.new(unresolved: access.unresolved) if strict && !access.resolved?
|
|
131
185
|
|
|
132
186
|
access
|
|
133
187
|
end
|
|
188
|
+
|
|
189
|
+
# Reads and writes for any number of statements, a migration or a whole log line - with the
|
|
190
|
+
# same arguments as `access`. Each statement sees the schema as the statements before it left
|
|
191
|
+
# it: a copy of the catalog is changed as they create, alter and drop things, and the catalog
|
|
192
|
+
# passed in stays as it was
|
|
193
|
+
def self.summary(sql, catalog: nil, strict: true)
|
|
194
|
+
statements = statements(sql)
|
|
195
|
+
catalog = catalog&.dup
|
|
196
|
+
accesses = statements.map do |statement|
|
|
197
|
+
access(statement, catalog:, strict:).tap { catalog&.apply(statement) }
|
|
198
|
+
end
|
|
199
|
+
Summary.new(statements, accesses)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.statements(sql)
|
|
203
|
+
case sql
|
|
204
|
+
when Node then [sql]
|
|
205
|
+
when Array then sql
|
|
206
|
+
else Parser.new(sql).parse
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
private_class_method :statements
|
|
134
210
|
end
|
|
135
211
|
end
|