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,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# What the read analysis knows about a database: its tables with their columns, and its
|
|
5
|
+
# views. Names are matched case-insensitively, the way SQLite matches identifiers, and
|
|
6
|
+
# reported in the spelling they were declared with.
|
|
7
|
+
class Catalog
|
|
8
|
+
ROWID_NAMES = %w[rowid oid _rowid_].freeze
|
|
9
|
+
|
|
10
|
+
# `columns` is nil when they cannot be known, like a CREATE TABLE … AS SELECT * from a table
|
|
11
|
+
# the catalog lacks; any column name is then taken as written
|
|
12
|
+
Table = Data.define(:schema, :name, :columns, :hidden_columns, :rowid_alias, :without_rowid) do
|
|
13
|
+
def column(name)
|
|
14
|
+
return name.to_s unless columns
|
|
15
|
+
|
|
16
|
+
needle = name.to_s.downcase
|
|
17
|
+
columns.find { |c| c.downcase == needle } || hidden_columns.find { |c| c.downcase == needle }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def rowid? = !without_rowid
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
View = Data.define(:schema, :name, :columns, :select)
|
|
24
|
+
|
|
25
|
+
def self.from_sql(sql) = new.load_sql(sql)
|
|
26
|
+
|
|
27
|
+
# Takes a sqlite3-ruby `SQLite3::Database` (or anything with a compatible `#execute`)
|
|
28
|
+
def self.from_sqlite(db) = new.load_sqlite(db)
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@tables = {}
|
|
32
|
+
@views = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_table(name, columns, schema: "main", hidden_columns: [], rowid_alias: nil, without_rowid: false)
|
|
36
|
+
table = Table.new(schema:, name:, columns: columns.freeze, hidden_columns: hidden_columns.freeze, rowid_alias:, without_rowid:)
|
|
37
|
+
@tables[key(schema, name)] = table
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `select` is a `SelectStatement` or the SQL for one
|
|
41
|
+
def add_view(name, select, columns: nil, schema: "main")
|
|
42
|
+
select = Parser.new(select).parse.first if String === select
|
|
43
|
+
@views[key(schema, name)] = View.new(schema:, name:, columns: columns&.freeze, select:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Unqualified names are looked up the way SQLite does: temp first, then main,
|
|
47
|
+
# then attached databases in the order they were added
|
|
48
|
+
def table(name, schema: nil) = lookup(@tables, name, schema)
|
|
49
|
+
def view(name, schema: nil) = lookup(@views, name, schema)
|
|
50
|
+
|
|
51
|
+
def tables = @tables.values
|
|
52
|
+
def views = @views.values
|
|
53
|
+
|
|
54
|
+
# Statements other than CREATE TABLE and CREATE VIEW (indexes, triggers, inserts…) do not
|
|
55
|
+
# change what can be read, and are skipped without being parsed
|
|
56
|
+
def load_sql(sql)
|
|
57
|
+
Catalog.split_statements(sql).each do |statement_sql|
|
|
58
|
+
leading = Plume.tokenize(statement_sql).first(3) - [:TEMP, :TEMPORARY]
|
|
59
|
+
next unless leading[0] == :CREATE && (leading[1] in :TABLE | :VIEW)
|
|
60
|
+
|
|
61
|
+
case (statement = Parser.new(statement_sql).parse.first)
|
|
62
|
+
when CreateTableStatement then add_create_table(statement)
|
|
63
|
+
when CreateViewStatement then add_create_view(statement)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def load_sqlite(db)
|
|
70
|
+
db.execute("PRAGMA database_list").each do |(_seq, schema, _file)|
|
|
71
|
+
master = (schema == "temp") ? "sqlite_temp_master" : "#{quote(schema)}.sqlite_master"
|
|
72
|
+
db.execute("SELECT type, name, sql FROM #{master} WHERE type IN ('table', 'view')").each do |(type, name, sql)|
|
|
73
|
+
next if name.start_with?("sqlite_")
|
|
74
|
+
|
|
75
|
+
if type == "view"
|
|
76
|
+
statement = Parser.new(sql).parse.first
|
|
77
|
+
add_view(name, statement.select, columns: statement.column_names, schema:)
|
|
78
|
+
else
|
|
79
|
+
add_sqlite_table(db, schema, name, sql)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Splits SQL into statements on top-level semicolons, keeping trigger bodies
|
|
87
|
+
# (`BEGIN …; …; END`) in one piece
|
|
88
|
+
def self.split_statements(sql)
|
|
89
|
+
lexer = Lexer.new(sql, skip_spaces: true)
|
|
90
|
+
statements = []
|
|
91
|
+
start = 0
|
|
92
|
+
recent = []
|
|
93
|
+
trigger_depth = 0
|
|
94
|
+
in_trigger = false
|
|
95
|
+
|
|
96
|
+
while (token = lexer.next_token)
|
|
97
|
+
recent = [*recent.last(2), token]
|
|
98
|
+
in_trigger = true if token == :TRIGGER && recent.include?(:CREATE)
|
|
99
|
+
|
|
100
|
+
if in_trigger && token == :BEGIN
|
|
101
|
+
trigger_depth += 1
|
|
102
|
+
elsif in_trigger && token == :END && trigger_depth > 0
|
|
103
|
+
trigger_depth -= 1
|
|
104
|
+
in_trigger = false if trigger_depth.zero?
|
|
105
|
+
elsif token == :SEMI && trigger_depth.zero?
|
|
106
|
+
statements << sql.byteslice(start, lexer.cursor - start)
|
|
107
|
+
start = lexer.cursor
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
statements << sql.byteslice(start, sql.bytesize - start)
|
|
111
|
+
statements.reject { |s| Plume.tokenize(s).reject { |t| t == :SEMI }.empty? }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def key(schema, name) = [schema.to_s.downcase, name.to_s.downcase].freeze
|
|
117
|
+
|
|
118
|
+
def lookup(collection, name, schema)
|
|
119
|
+
return collection[key(schema, name)] if schema
|
|
120
|
+
|
|
121
|
+
collection[key("temp", name)] || collection[key("main", name)] ||
|
|
122
|
+
collection.find { |(_s, n), _| n == name.to_s.downcase }&.last
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def schema_of(name, statement) = name.schema || (statement.temporary ? "temp" : "main")
|
|
126
|
+
|
|
127
|
+
def add_create_table(statement)
|
|
128
|
+
schema = schema_of(statement.table, statement)
|
|
129
|
+
name = statement.table.name
|
|
130
|
+
|
|
131
|
+
if statement.select_statement
|
|
132
|
+
columns = Analysis::Resolver.new(catalog: self).output_columns(statement.select_statement)
|
|
133
|
+
add_table(name, (columns unless columns.include?(nil)), schema:)
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
columns = statement.columns.map(&:name)
|
|
138
|
+
add_table(name, columns, schema:, rowid_alias: rowid_alias(statement), without_rowid: statement.without_row_id)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def add_create_view(statement)
|
|
142
|
+
schema = schema_of(statement.view, statement)
|
|
143
|
+
add_view(statement.view.name, statement.select, columns: statement.column_names, schema:)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A column becomes another name for the rowid when it is the whole primary key and is
|
|
147
|
+
# declared as exactly INTEGER - not INT, not BIGINT. See https://www.sqlite.org/lang_createtable.html#rowid
|
|
148
|
+
def rowid_alias(statement)
|
|
149
|
+
return if statement.without_row_id
|
|
150
|
+
|
|
151
|
+
candidate = statement.columns.find do |column|
|
|
152
|
+
column.constraints&.any? { |c| PrimaryKeyColumnConstraint === c && c.direction != :DESC }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
unless candidate
|
|
156
|
+
table_pk = statement.constraints&.find { |c| PrimaryKeyTableConstraint === c }
|
|
157
|
+
if table_pk && table_pk.columns.size == 1
|
|
158
|
+
pk_name = table_pk.columns[0].name.downcase
|
|
159
|
+
candidate = statement.columns.find { |c| c.name.downcase == pk_name }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
candidate.name if candidate&.type&.text&.casecmp?("INTEGER")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def add_sqlite_table(db, schema, name, sql)
|
|
167
|
+
info = db.execute("PRAGMA #{quote(schema)}.table_xinfo(#{quote(name)})")
|
|
168
|
+
# hidden is 0 for ordinary columns, 1 for hidden virtual table columns, 2 and 3 for generated ones
|
|
169
|
+
columns = info.reject { |row| row[6] == 1 }.map { |row| row[1] }
|
|
170
|
+
hidden = info.select { |row| row[6] == 1 }.map { |row| row[1] }
|
|
171
|
+
without_rowid = sql.to_s.match?(/\bWITHOUT\s+ROWID\s*;?\s*\z/i)
|
|
172
|
+
pk_rows = info.select { |row| row[5].to_i > 0 }
|
|
173
|
+
rowid_alias = pk_rows[0][1] if !without_rowid && pk_rows.size == 1 && pk_rows[0][2].to_s.casecmp?("INTEGER")
|
|
174
|
+
|
|
175
|
+
add_table(name, columns, schema:, hidden_columns: hidden, rowid_alias:, without_rowid:)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def quote(identifier) = %("#{identifier.to_s.gsub('"', '""')}")
|
|
179
|
+
end
|
|
180
|
+
end
|