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.
@@ -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?(qualifier)
22
+ return false unless name&.casecmp(qualifier)&.zero?
23
23
  return true unless schema_qualifier
24
24
 
25
- schema&.casecmp?(schema_qualifier) || (schema.nil? && schema_qualifier.casecmp?("main"))
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, nil)
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
- target.columns.each { |column| write(target, column, :insert, statement.table) }
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, nil)
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
- add_source(statement.source, scope, :projected) if statement.source
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, nil)
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
- add_source(core.source, scope, usage) if core.source
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, LiteralExpression then nil
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 then node.children.each { |child| expression(child, scope, usage, aliases:) }
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
@@ -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?("main")) ? table : "#{schema}.#{table}"
46
+ (schema.nil? || schema.casecmp("main")&.zero?) ? table : "#{schema}.#{table}"
46
47
  end
47
48
 
48
49
  class References
49
- attr_reader :statement, :references, :unresolved
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
- attr_reader :views, :functions
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
- Access = Data.define(:reads, :writes) do
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
- # Like `reads`, but also reports what an INSERT, UPDATE or DELETE writes
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
- unless Resolver::STATEMENTS.any? { |klass| klass === statement }
126
- raise Error, "only SELECT, INSERT, UPDATE and DELETE can be analyzed, got #{statement.class.name}"
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