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
|
@@ -3,21 +3,30 @@
|
|
|
3
3
|
module Plume
|
|
4
4
|
# **[SQLite Docs](https://www.sqlite.org/lang_altertable.html)**
|
|
5
5
|
#
|
|
6
|
-
# `action` is :RENAME_TABLE, :RENAME_COLUMN, :ADD_COLUMN
|
|
6
|
+
# `action` is :RENAME_TABLE, :RENAME_COLUMN, :ADD_COLUMN, :DROP_COLUMN, :ADD_CONSTRAINT (a CHECK),
|
|
7
|
+
# :DROP_CONSTRAINT, :SET_NOT_NULL or :DROP_NOT_NULL
|
|
7
8
|
class AlterTableStatement < Node
|
|
8
|
-
Action = _Union(
|
|
9
|
+
Action = _Union(
|
|
10
|
+
:RENAME_TABLE, :RENAME_COLUMN, :ADD_COLUMN, :DROP_COLUMN,
|
|
11
|
+
:ADD_CONSTRAINT, :DROP_CONSTRAINT, :SET_NOT_NULL, :DROP_NOT_NULL,
|
|
12
|
+
)
|
|
9
13
|
|
|
10
14
|
token :alter_table_kw
|
|
11
|
-
token :action_kw # RENAME [COLUMN], ADD [COLUMN]
|
|
15
|
+
token :action_kw # RENAME [COLUMN], ADD [COLUMN], DROP [COLUMN], DROP CONSTRAINT or ALTER [COLUMN]
|
|
12
16
|
token :column_tk
|
|
13
17
|
token :to_kw
|
|
14
18
|
token :new_name_tk
|
|
19
|
+
token :constraint_name_tk
|
|
20
|
+
token :not_null_kw # SET NOT NULL or DROP NOT NULL
|
|
15
21
|
|
|
16
22
|
node :table, TableName
|
|
17
23
|
attribute :action, Action
|
|
18
24
|
node :column_definition, ColumnDefinition
|
|
25
|
+
node :constraint, CheckTableConstraint
|
|
26
|
+
node :conflict_clause, ConflictClause
|
|
19
27
|
attribute :column, Stringy
|
|
20
28
|
attribute :new_name, Stringy
|
|
29
|
+
attribute :constraint_name, Stringy
|
|
21
30
|
|
|
22
31
|
def self.new(*, table:, action:, **) = super
|
|
23
32
|
def self.concrete(*, alter_table_kw:, table:, action:, **) = super
|
|
@@ -25,5 +34,7 @@ module Plume
|
|
|
25
34
|
def action = @action
|
|
26
35
|
def column = (@column == LiteralNil) ? column_tk_val : @column
|
|
27
36
|
def new_name = (@new_name == LiteralNil) ? new_name_tk_val : @new_name
|
|
37
|
+
# The constraint dropped, or added - which keeps its name on `constraint`
|
|
38
|
+
def constraint_name = (@constraint_name == LiteralNil) ? (constraint_name_tk_val || constraint&.name) : @constraint_name
|
|
28
39
|
end
|
|
29
40
|
end
|
|
@@ -14,6 +14,6 @@ module Plume
|
|
|
14
14
|
def self.concrete(*, collate_kw:, collation_tk:, **) = super
|
|
15
15
|
|
|
16
16
|
def name = (@name == LiteralNil) ? name_tk_val : @name
|
|
17
|
-
def collation = (@collation == LiteralNil) ? collation_tk_val
|
|
17
|
+
def collation = (@collation == LiteralNil) ? Node.collation_name(collation_tk_val) : @collation
|
|
18
18
|
end
|
|
19
19
|
end
|
|
@@ -21,6 +21,6 @@ module Plume
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def expression = (@expression == LiteralNil) ? expression_tk_val : @expression
|
|
24
|
-
def sequence = (@sequence == LiteralNil) ? sequence_tk_val
|
|
24
|
+
def sequence = (@sequence == LiteralNil) ? Node.collation_name(sequence_tk_val) : @sequence
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -13,7 +13,8 @@ module Plume
|
|
|
13
13
|
|
|
14
14
|
inspectable def affinity
|
|
15
15
|
# rules found at: https://www.sqlite.org/datatype3.html#determination_of_column_affinity
|
|
16
|
-
|
|
16
|
+
# Matched as bytes, as SQLite does, so a type name that is not valid UTF-8 has one too
|
|
17
|
+
case text&.b
|
|
17
18
|
when /INT/i then :INTEGER
|
|
18
19
|
when /CHAR|CLOB|TEXT/i then :TEXT
|
|
19
20
|
when /BLOB/i then :BLOB
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# `[NOT] DEFERRABLE [INITIALLY DEFERRED | INITIALLY IMMEDIATE]` on its own among a column's
|
|
5
|
+
# constraints, where SQLite's grammar allows it (`ccons ::= defer_subclause`)
|
|
6
|
+
class DeferrableColumnConstraint < ColumnConstraint
|
|
7
|
+
token :constraint_kw
|
|
8
|
+
token :name_tk
|
|
9
|
+
token :deferrable_span
|
|
10
|
+
|
|
11
|
+
attribute :name, Stringy
|
|
12
|
+
attribute :deferred, ForeignKeyClause::Deferrable
|
|
13
|
+
|
|
14
|
+
def self.new(*, **) = super
|
|
15
|
+
def self.concrete(*, deferrable_span:, **) = super
|
|
16
|
+
|
|
17
|
+
def name = (@name == LiteralNil) ? name_tk_val : @name
|
|
18
|
+
def deferred = (@deferred == LiteralNil) ? ForeignKeyClause::TOKEN_TO_DEFERRABLE[deferrable_span_tok] : @deferred
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -12,6 +12,7 @@ module Plume
|
|
|
12
12
|
def self.new(*, expressions:, **) = super
|
|
13
13
|
def self.concrete(*, expressions:, **) = super
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# ALL is the default, written out - only DISTINCT makes the aggregate distinct
|
|
16
|
+
def distinct = (@distinct == LiteralNil) ? (distinct_kw_tok == :DISTINCT) : @distinct
|
|
16
17
|
end
|
|
17
18
|
end
|
|
@@ -6,16 +6,19 @@ module Plume
|
|
|
6
6
|
token :collate_kw
|
|
7
7
|
token :collation_tk
|
|
8
8
|
token :direction_tk
|
|
9
|
+
token :nulls_tk
|
|
9
10
|
|
|
10
11
|
attribute :name, Stringy
|
|
11
12
|
attribute :collation, Stringy
|
|
12
13
|
attribute :direction, Stringy
|
|
14
|
+
attribute :nulls, OrderingTerm::Nulls
|
|
13
15
|
|
|
14
16
|
def self.new(*, name:, **) = super
|
|
15
17
|
def self.concrete(*, name_tk:, **) = super
|
|
16
18
|
|
|
17
19
|
def name = (@name == LiteralNil) ? name_tk_val : @name
|
|
18
|
-
def collation = (@collation == LiteralNil) ? collation_tk_val
|
|
20
|
+
def collation = (@collation == LiteralNil) ? Node.collation_name(collation_tk_val) : @collation
|
|
19
21
|
def direction = (@direction == LiteralNil) ? direction_tk_val&.to_sym&.upcase : @direction
|
|
22
|
+
def nulls = (@nulls == LiteralNil) ? nulls_tk_tok&.last : @nulls
|
|
20
23
|
end
|
|
21
24
|
end
|
data/lib/plume/ast/node.rb
CHANGED
|
@@ -6,10 +6,38 @@ module Plume
|
|
|
6
6
|
|
|
7
7
|
Stringy = _Union(String, Symbol)
|
|
8
8
|
|
|
9
|
+
def self.literal_properties
|
|
10
|
+
super
|
|
11
|
+
rescue => error
|
|
12
|
+
raise unless defined?(Ractor::IsolationError) && Ractor::IsolationError === error
|
|
13
|
+
|
|
14
|
+
raise Ractor::IsolationError, "#{name} is not shareable yet, call Plume.eager_load! from the main Ractor first " \
|
|
15
|
+
"(or #{name}.make_shareable! for a class defined after it)"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.make_shareable!
|
|
19
|
+
Plume.make_shareable(literal_properties)
|
|
20
|
+
Plume.make_shareable(@inspectable_props)
|
|
21
|
+
Plume.make_shareable(@token_names)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.prop(name, ...)
|
|
26
|
+
raise FrozenError, "cannot add #{name} to #{self.name} after Plume.eager_load! has frozen its props" if literal_properties.frozen?
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
singleton_class.prepend(LiteralPatches::NodeSchema) if LiteralPatches::SCHEMA_HOLDS_MUTEX
|
|
32
|
+
|
|
9
33
|
prop :full_source, _Nilable(String)
|
|
10
34
|
prop :leading, _Nilable(Token)
|
|
11
35
|
prop :trailing, _Nilable(Token)
|
|
12
36
|
|
|
37
|
+
# A collation's name, upcased as a symbol. A symbol cannot hold bytes that
|
|
38
|
+
# are not valid UTF-8, so a name with those stays the string it was
|
|
39
|
+
def self.collation_name(name) = name&.valid_encoding? ? name.to_sym.upcase : name
|
|
40
|
+
|
|
13
41
|
def self.inspectable(*props)
|
|
14
42
|
@inspectable_props ||= Set.new
|
|
15
43
|
@inspectable_props.merge(props)
|
|
@@ -65,16 +93,94 @@ module Plume
|
|
|
65
93
|
end
|
|
66
94
|
end
|
|
67
95
|
|
|
96
|
+
# Yields each direct child with the prop it sits in, and its index when that prop holds an array
|
|
97
|
+
def each_child
|
|
98
|
+
# Each as one array: TruffleRuby's Enumerator hands only the first of several yielded values to `count`
|
|
99
|
+
return Enumerator.new { |y| each_child { |*slot| y << slot } } unless block_given?
|
|
100
|
+
|
|
101
|
+
self.class.literal_properties.each do |property|
|
|
102
|
+
case (value = instance_variable_get(:"@#{property.name}"))
|
|
103
|
+
when Node then yield value, property.name, nil
|
|
104
|
+
when Array then value.each_with_index { |v, i| yield v, property.name, i if Node === v }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
68
110
|
# Yields every node in the subtree depth-first, parents before their children,
|
|
69
|
-
# along with the parent (nil for the receiver)
|
|
70
|
-
|
|
71
|
-
|
|
111
|
+
# along with the parent (nil for the receiver). Without recursing, so a tree as
|
|
112
|
+
# deep as SQLite allows does not run out of stack
|
|
113
|
+
def walk(parent = nil)
|
|
114
|
+
return enum_for(:walk, parent) unless block_given?
|
|
115
|
+
|
|
116
|
+
stack = [[self, parent]]
|
|
117
|
+
while (node, up = stack.pop)
|
|
118
|
+
yield node, up
|
|
119
|
+
node.children.reverse_each { |child| stack << [child, node] }
|
|
120
|
+
end
|
|
121
|
+
self
|
|
122
|
+
end
|
|
72
123
|
|
|
73
|
-
|
|
74
|
-
|
|
124
|
+
# Like `walk`, but also says where under the parent each node sits: the prop's name,
|
|
125
|
+
# and the index into it when it holds an array
|
|
126
|
+
def walk_with_slots(parent = nil)
|
|
127
|
+
return Enumerator.new { |y| walk_with_slots(parent) { |*slot| y << slot } } unless block_given?
|
|
128
|
+
|
|
129
|
+
stack = [[self, parent, nil, nil]]
|
|
130
|
+
while (node, up, prop, index = stack.pop)
|
|
131
|
+
yield node, up, prop, index
|
|
132
|
+
start = stack.size
|
|
133
|
+
node.each_child { |child, name, i| stack << [child, node, name, i] }
|
|
134
|
+
stack[start..] = stack[start..].reverse
|
|
135
|
+
end
|
|
75
136
|
self
|
|
76
137
|
end
|
|
77
138
|
|
|
139
|
+
# A copy of the tree with nodes replaced. The block sees every node as `walk_with_slots` does,
|
|
140
|
+
# and returns it to keep it, or another node to put in its place - which is not descended into.
|
|
141
|
+
# Nodes on the way to a replacement are copied, everything else is shared with the original,
|
|
142
|
+
# which stays as it was. A replacement built with `.new` has no source, so print the result
|
|
143
|
+
# with `to_sql` rather than reading its `source_text`
|
|
144
|
+
def transform
|
|
145
|
+
replaced = {}.compare_by_identity
|
|
146
|
+
kept = []
|
|
147
|
+
stack = [[self, nil, nil, nil]]
|
|
148
|
+
while (node, up, prop, index = stack.pop)
|
|
149
|
+
replacement = yield node, up, prop, index
|
|
150
|
+
if replacement.equal?(node)
|
|
151
|
+
kept << node
|
|
152
|
+
node.each_child.reverse_each { |child, name, i| stack << [child, node, name, i] }
|
|
153
|
+
else
|
|
154
|
+
replaced[node] = replacement
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Parents come before their children in `kept`, so going backwards rebuilds children first
|
|
159
|
+
kept.reverse_each do |node|
|
|
160
|
+
changes = {}
|
|
161
|
+
node.each_child do |child, name, i|
|
|
162
|
+
next unless replaced.key?(child)
|
|
163
|
+
|
|
164
|
+
changes[name] ||= node.instance_variable_get(:"@#{name}").dup if i
|
|
165
|
+
i ? changes[name][i] = replaced[child] : changes[name] = replaced[child]
|
|
166
|
+
end
|
|
167
|
+
replaced[node] = node.with_props(changes) unless changes.empty?
|
|
168
|
+
end
|
|
169
|
+
replaced.fetch(self, self)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A copy with some props set to new values, checked against their types
|
|
173
|
+
def with_props(changes)
|
|
174
|
+
copy = dup
|
|
175
|
+
changes.each do |name, value|
|
|
176
|
+
property = self.class.literal_properties[name]
|
|
177
|
+
raise TypeError, "#{self.class.name}##{name} cannot be #{value.inspect}" unless property.type === value
|
|
178
|
+
|
|
179
|
+
copy.instance_variable_set(:"@#{name}", value)
|
|
180
|
+
end
|
|
181
|
+
copy
|
|
182
|
+
end
|
|
183
|
+
|
|
78
184
|
def each_node(&block)
|
|
79
185
|
return enum_for(:each_node) unless block
|
|
80
186
|
|
|
@@ -93,7 +199,23 @@ module Plume
|
|
|
93
199
|
[beg, fin].freeze if beg
|
|
94
200
|
end
|
|
95
201
|
|
|
96
|
-
|
|
202
|
+
# `pretty: true` lays the SQL out over lines, see PrettyPrinter for `indent_size:`, `max_line_length:`
|
|
203
|
+
# and `commas_start_of_line:`. With `comments: true`, the comments right before and after the node
|
|
204
|
+
# in its source come along - a header above a statement, or the sqlcommenter tags after it. Those
|
|
205
|
+
# inside it are left out. A `--` comment after it becomes a `/* */` one, so a semicolon after it is
|
|
206
|
+
# not swallowed
|
|
207
|
+
def to_sql(pretty: false, comments: false, **layout)
|
|
208
|
+
sql = pretty ? PrettyPrinter.print(self, **layout) : Printer.print(self)
|
|
209
|
+
return sql unless comments
|
|
210
|
+
|
|
211
|
+
before, after = Comments.around(self)
|
|
212
|
+
lead = before.map { |c| c.text + (c.block? ? " " : "\n") }.join
|
|
213
|
+
tail = after.map { |c| " #{((c.block? || c.body.include?("*/")) ? c.text : "/*#{c.body} */")}" }.join
|
|
214
|
+
lead + sql + tail
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A plain hash of the whole tree that JSON can carry, which `Plume.load` turns back into it
|
|
218
|
+
def dump = Dump.dump(self)
|
|
97
219
|
|
|
98
220
|
def source_text
|
|
99
221
|
return unless @full_source && (loc = location)
|
|
@@ -195,13 +317,14 @@ module Plume
|
|
|
195
317
|
define_node_reader(name)
|
|
196
318
|
end
|
|
197
319
|
|
|
198
|
-
# Absent children read as nil rather than the LiteralNil default
|
|
320
|
+
# Absent children read as nil rather than the LiteralNil default. Evaluated from a string
|
|
321
|
+
# because a method defined with a block cannot be called from another Ractor
|
|
199
322
|
def self.define_node_reader(name)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
323
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
|
324
|
+
def #{name}
|
|
325
|
+
LiteralNil.equal?(@#{name}) ? nil : @#{name}
|
|
326
|
+
end
|
|
327
|
+
RUBY
|
|
205
328
|
end
|
|
206
329
|
end
|
|
207
330
|
end
|
|
@@ -25,7 +25,7 @@ module Plume
|
|
|
25
25
|
def self.new(*, **) = super
|
|
26
26
|
def self.concrete(*, **) = super
|
|
27
27
|
|
|
28
|
-
def collation = (@collation == LiteralNil) ? collation_tk_val
|
|
28
|
+
def collation = (@collation == LiteralNil) ? Node.collation_name(collation_tk_val) : @collation
|
|
29
29
|
def direction = (@direction == LiteralNil) ? direction_tk_val&.to_sym&.upcase : @direction
|
|
30
30
|
def nulls = (@nulls == LiteralNil) ? nulls_tk_val&.to_sym&.upcase : @nulls
|
|
31
31
|
end
|
data/lib/plume/ast/token.rb
CHANGED
|
@@ -148,9 +148,12 @@ module Plume
|
|
|
148
148
|
src = source(str, tup)
|
|
149
149
|
bytesize = src.bytesize
|
|
150
150
|
# strip the leading and trailing quotes, a quote inside is written twice
|
|
151
|
-
src.byteslice(1, bytesize - 2)
|
|
151
|
+
undoubled(src.byteslice(1, bytesize - 2), "'")
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# Done on the bytes, since TruffleRuby's gsub refuses a string that is not valid UTF-8
|
|
155
|
+
def undoubled(text, quote) = text.b.gsub(quote * 2, quote).force_encoding(text.encoding)
|
|
156
|
+
|
|
154
157
|
def blob_literal(str, tup)
|
|
155
158
|
src = source(str, tup)
|
|
156
159
|
bytesize = src.bytesize
|
|
@@ -169,9 +172,9 @@ module Plume
|
|
|
169
172
|
|
|
170
173
|
# Inside quotes, the quote character itself is written twice. Brackets have no escape
|
|
171
174
|
case src.getbyte(0)
|
|
172
|
-
when 96 then src.byteslice(1, src.bytesize - 2)
|
|
173
|
-
when 39 then src.byteslice(1, src.bytesize - 2)
|
|
174
|
-
when 34 then src.byteslice(1, src.bytesize - 2)
|
|
175
|
+
when 96 then undoubled(src.byteslice(1, src.bytesize - 2), "`")
|
|
176
|
+
when 39 then undoubled(src.byteslice(1, src.bytesize - 2), "'")
|
|
177
|
+
when 34 then undoubled(src.byteslice(1, src.bytesize - 2), '"')
|
|
175
178
|
when 91 then src.byteslice(1, src.bytesize - 2)
|
|
176
179
|
else src
|
|
177
180
|
end
|
data/lib/plume/catalog.rb
CHANGED
|
@@ -1,39 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Plume
|
|
4
|
-
# What the read analysis knows about a database: its tables with their columns,
|
|
5
|
-
#
|
|
4
|
+
# What the read analysis knows about a database: its tables with their columns, its views,
|
|
5
|
+
# and which table each index and trigger belongs to. Names are matched case-insensitively, the way SQLite matches identifiers, and
|
|
6
6
|
# reported in the spelling they were declared with.
|
|
7
7
|
class Catalog
|
|
8
8
|
ROWID_NAMES = %w[rowid oid _rowid_].freeze
|
|
9
9
|
|
|
10
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
|
-
|
|
11
|
+
# the catalog lacks; any column name is then taken as written. `generated_columns` are among
|
|
12
|
+
# `columns` too, since they can be read like any other, but nothing can write to them.
|
|
13
|
+
# `module_name` is set for a virtual table, like "fts5"
|
|
14
|
+
Table = Data.define(:schema, :name, :columns, :hidden_columns, :generated_columns, :rowid_alias, :without_rowid, :module_name) do
|
|
13
15
|
def column(name)
|
|
14
16
|
return name.to_s unless columns
|
|
15
17
|
|
|
16
|
-
needle = name.to_s.downcase
|
|
17
|
-
columns.find { |c| c.downcase == needle } || hidden_columns.find { |c| c.downcase == needle }
|
|
18
|
+
needle = name.to_s.downcase(:ascii)
|
|
19
|
+
columns.find { |c| c.downcase(:ascii) == needle } || hidden_columns.find { |c| c.downcase(:ascii) == needle }
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def rowid? = !without_rowid
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
View = Data.define(:schema, :name, :columns, :select)
|
|
26
|
+
Index = Data.define(:schema, :name, :table)
|
|
27
|
+
Trigger = Data.define(:schema, :name, :table)
|
|
24
28
|
|
|
25
29
|
def self.from_sql(sql) = new.load_sql(sql)
|
|
26
30
|
|
|
31
|
+
# Whether two names are one to SQLite, which folds the case of A-Z only. Compared as bytes, so a
|
|
32
|
+
# name that is not valid UTF-8 compares too - JRuby's casecmp? raises for one
|
|
33
|
+
def self.same_name?(a, b) = !a.nil? && !b.nil? && a.to_s.b.casecmp?(b.to_s.b)
|
|
34
|
+
|
|
35
|
+
# The indexes SQLite makes for a table's UNIQUE and PRIMARY KEY constraints, in the order it
|
|
36
|
+
# makes them - all but the rowid's own primary key, even where two cover the same columns
|
|
37
|
+
def self.autoindexes(statement)
|
|
38
|
+
return [] if statement.select_statement
|
|
39
|
+
|
|
40
|
+
alias_column = new.__send__(:rowid_alias, statement)&.downcase(:ascii)
|
|
41
|
+
constraints = statement.columns.flat_map do |column|
|
|
42
|
+
(column.constraints || []).select { |c| UniqueColumnConstraint === c || (PrimaryKeyColumnConstraint === c && column.name.downcase(:ascii) != alias_column) }
|
|
43
|
+
end
|
|
44
|
+
constraints += (statement.constraints || []).select do |c|
|
|
45
|
+
UniqueTableConstraint === c || (PrimaryKeyTableConstraint === c && !(alias_column && c.columns.size == 1))
|
|
46
|
+
end
|
|
47
|
+
Array.new(constraints.size) { |i| "sqlite_autoindex_#{statement.table.name}_#{i + 1}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
27
50
|
# Takes a sqlite3-ruby `SQLite3::Database` (or anything with a compatible `#execute`)
|
|
28
51
|
def self.from_sqlite(db) = new.load_sqlite(db)
|
|
29
52
|
|
|
30
53
|
def initialize
|
|
31
54
|
@tables = {}
|
|
32
55
|
@views = {}
|
|
56
|
+
@indexes = {}
|
|
57
|
+
@triggers = {}
|
|
33
58
|
end
|
|
34
59
|
|
|
35
|
-
def add_table(name, columns, schema: "main", hidden_columns: [], rowid_alias: nil, without_rowid: false)
|
|
36
|
-
table = Table.new(schema:, name:, columns: columns
|
|
60
|
+
def add_table(name, columns, schema: "main", hidden_columns: [], generated_columns: [], rowid_alias: nil, without_rowid: false, module_name: nil)
|
|
61
|
+
table = Table.new(schema:, name:, columns: columns&.freeze, hidden_columns: hidden_columns.freeze, generated_columns: generated_columns.freeze, rowid_alias:, without_rowid:, module_name:)
|
|
37
62
|
@tables[key(schema, name)] = table
|
|
38
63
|
end
|
|
39
64
|
|
|
@@ -43,83 +68,147 @@ module Plume
|
|
|
43
68
|
@views[key(schema, name)] = View.new(schema:, name:, columns: columns&.freeze, select:)
|
|
44
69
|
end
|
|
45
70
|
|
|
71
|
+
# `table` is the name of the table the index or trigger is on, in the same schema
|
|
72
|
+
def add_index(name, table, schema: "main") = @indexes[key(schema, name)] = Index.new(schema:, name:, table:)
|
|
73
|
+
def add_trigger(name, table, schema: "main") = @triggers[key(schema, name)] = Trigger.new(schema:, name:, table:)
|
|
74
|
+
|
|
46
75
|
# Unqualified names are looked up the way SQLite does: temp first, then main,
|
|
47
76
|
# then attached databases in the order they were added
|
|
48
77
|
def table(name, schema: nil) = lookup(@tables, name, schema)
|
|
49
78
|
def view(name, schema: nil) = lookup(@views, name, schema)
|
|
79
|
+
def index(name, schema: nil) = lookup(@indexes, name, schema)
|
|
80
|
+
def trigger(name, schema: nil) = lookup(@triggers, name, schema)
|
|
50
81
|
|
|
51
82
|
def tables = @tables.values
|
|
52
83
|
def views = @views.values
|
|
84
|
+
def indexes = @indexes.values
|
|
85
|
+
def triggers = @triggers.values
|
|
53
86
|
|
|
54
|
-
# Statements other than CREATE
|
|
55
|
-
#
|
|
87
|
+
# Statements other than CREATE (inserts, pragmas…) do not change the schema, and are skipped
|
|
88
|
+
# without being parsed
|
|
56
89
|
def load_sql(sql)
|
|
57
90
|
Catalog.split_statements(sql).each do |statement_sql|
|
|
58
91
|
leading = Plume.tokenize(statement_sql).first(3) - [:TEMP, :TEMPORARY]
|
|
59
|
-
next unless leading[0] == :CREATE && (leading[1] in :TABLE | :VIEW)
|
|
92
|
+
next unless leading[0] == :CREATE && (leading[1] in :TABLE | :VIEW | :INDEX | :UNIQUE | :TRIGGER | :VIRTUAL)
|
|
60
93
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
apply(Parser.new(statement_sql).parse.first)
|
|
95
|
+
end
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Changes the catalog the way running `statement` would change the schema: CREATE adds,
|
|
100
|
+
# DROP removes, ALTER TABLE renames, adds and drops columns. Other statements change nothing
|
|
101
|
+
def apply(statement)
|
|
102
|
+
case statement
|
|
103
|
+
when CreateTableStatement then add_create_table(statement) unless statement.if_not_exists && table(statement.table.name, schema: statement.table.schema)
|
|
104
|
+
when CreateViewStatement then add_create_view(statement) unless statement.if_not_exists && view(statement.view.name, schema: statement.view.schema)
|
|
105
|
+
when CreateVirtualTableStatement
|
|
106
|
+
add_table(statement.table.name, nil, schema: statement.table.schema || "main", module_name: statement.module_name)
|
|
107
|
+
when CreateIndexStatement
|
|
108
|
+
schema = statement.index.schema || table(statement.table)&.schema || "main"
|
|
109
|
+
add_index(statement.index.name, statement.table, schema:)
|
|
110
|
+
when CreateTriggerStatement
|
|
111
|
+
schema = statement.trigger.schema || (statement.temporary ? "temp" : table(statement.table.table)&.schema || "main")
|
|
112
|
+
add_trigger(statement.trigger.name, statement.table.table, schema:)
|
|
113
|
+
when DropStatement then drop(statement.type, statement.target)
|
|
114
|
+
when AlterTableStatement then alter(statement)
|
|
65
115
|
end
|
|
66
116
|
self
|
|
67
117
|
end
|
|
68
118
|
|
|
119
|
+
def initialize_copy(other)
|
|
120
|
+
super
|
|
121
|
+
@tables = @tables.dup
|
|
122
|
+
@views = @views.dup
|
|
123
|
+
@indexes = @indexes.dup
|
|
124
|
+
@triggers = @triggers.dup
|
|
125
|
+
end
|
|
126
|
+
|
|
69
127
|
def load_sqlite(db)
|
|
70
128
|
db.execute("PRAGMA database_list").each do |(_seq, schema, _file)|
|
|
71
129
|
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)|
|
|
130
|
+
db.execute("SELECT type, name, tbl_name, sql FROM #{master} WHERE type IN ('table', 'view', 'index', 'trigger')").each do |(type, name, table, sql)|
|
|
73
131
|
next if name.start_with?("sqlite_")
|
|
74
132
|
|
|
75
|
-
|
|
133
|
+
case type
|
|
134
|
+
when "view"
|
|
76
135
|
statement = Parser.new(sql).parse.first
|
|
77
136
|
add_view(name, statement.select, columns: statement.column_names, schema:)
|
|
78
|
-
|
|
79
|
-
|
|
137
|
+
when "index" then add_index(name, table, schema:)
|
|
138
|
+
when "trigger" then add_trigger(name, table, schema:)
|
|
139
|
+
else add_sqlite_table(db, schema, name, sql)
|
|
80
140
|
end
|
|
81
141
|
end
|
|
82
142
|
end
|
|
83
143
|
self
|
|
84
144
|
end
|
|
85
145
|
|
|
86
|
-
# Splits SQL into statements
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
146
|
+
# Splits SQL into statements, keeping trigger bodies (`BEGIN …; …; END`) in one piece
|
|
147
|
+
def self.split_statements(sql) = Plume.split(sql)
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def drop(type, target)
|
|
152
|
+
collection = { TABLE: @tables, VIEW: @views, INDEX: @indexes, TRIGGER: @triggers }.fetch(type)
|
|
153
|
+
object = lookup(collection, target.table, target.schema) or return
|
|
154
|
+
collection.delete(key(object.schema, object.name))
|
|
155
|
+
return unless type == :TABLE || type == :VIEW
|
|
156
|
+
|
|
157
|
+
# A table takes its indexes and triggers with it, a view its triggers
|
|
158
|
+
[@indexes, @triggers].each do |dependents|
|
|
159
|
+
dependents.delete_if { |_, d| Catalog.same_name?(d.schema, object.schema) && Catalog.same_name?(d.table, object.name) }
|
|
109
160
|
end
|
|
110
|
-
statements << sql.byteslice(start, sql.bytesize - start)
|
|
111
|
-
statements.reject { |s| Plume.tokenize(s).reject { |t| t == :SEMI }.empty? }
|
|
112
161
|
end
|
|
113
162
|
|
|
114
|
-
|
|
163
|
+
def alter(statement)
|
|
164
|
+
table = table(statement.table.table, schema: statement.table.schema) or return
|
|
165
|
+
columns = table.columns
|
|
166
|
+
rename = -> (list, from, to) { list&.map { |c| Catalog.same_name?(c, from) ? to : c } }
|
|
167
|
+
|
|
168
|
+
changed =
|
|
169
|
+
case statement.action
|
|
170
|
+
when :RENAME_TABLE then return rename_table(table, statement.new_name)
|
|
171
|
+
when :ADD_COLUMN
|
|
172
|
+
definition = statement.column_definition
|
|
173
|
+
generated = definition.constraints&.any?(GeneratedAsColumnConstraint)
|
|
174
|
+
table.with(columns: columns && [*columns, definition.name].freeze, generated_columns: generated ? [*table.generated_columns, definition.name].freeze : table.generated_columns)
|
|
175
|
+
when :RENAME_COLUMN
|
|
176
|
+
from, to = statement.column, statement.new_name
|
|
177
|
+
table.with(
|
|
178
|
+
columns: rename.(columns, from, to)&.freeze, generated_columns: rename.(table.generated_columns, from, to).freeze,
|
|
179
|
+
rowid_alias: Catalog.same_name?(table.rowid_alias, from) ? to : table.rowid_alias,
|
|
180
|
+
)
|
|
181
|
+
when :DROP_COLUMN
|
|
182
|
+
gone = -> (list) { list&.reject { |c| Catalog.same_name?(c, statement.column) }&.freeze }
|
|
183
|
+
table.with(columns: gone.(columns), generated_columns: gone.(table.generated_columns))
|
|
184
|
+
else return
|
|
185
|
+
end
|
|
186
|
+
@tables[key(table.schema, table.name)] = changed
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def rename_table(table, new_name)
|
|
190
|
+
@tables.delete(key(table.schema, table.name))
|
|
191
|
+
@tables[key(table.schema, new_name)] = table.with(name: new_name)
|
|
192
|
+
old_prefix = "sqlite_autoindex_#{table.name}_"
|
|
193
|
+
@indexes.transform_values! { |i| (Catalog.same_name?(i.schema, table.schema) && Catalog.same_name?(i.table, table.name)) ? i.with(table: new_name) : i }
|
|
194
|
+
@indexes.keys.each do |k|
|
|
195
|
+
index = @indexes[k]
|
|
196
|
+
next unless index.name.start_with?(old_prefix) && index.table == new_name
|
|
197
|
+
|
|
198
|
+
@indexes.delete(k)
|
|
199
|
+
renamed = index.with(name: index.name.sub(old_prefix, "sqlite_autoindex_#{new_name}_"))
|
|
200
|
+
@indexes[key(renamed.schema, renamed.name)] = renamed
|
|
201
|
+
end
|
|
202
|
+
@triggers.transform_values! { |t| (Catalog.same_name?(t.schema, table.schema) && Catalog.same_name?(t.table, table.name)) ? t.with(table: new_name) : t }
|
|
203
|
+
end
|
|
115
204
|
|
|
116
|
-
def key(schema, name) = [schema.to_s.downcase, name.to_s.downcase].freeze
|
|
205
|
+
def key(schema, name) = [schema.to_s.downcase(:ascii), name.to_s.downcase(:ascii)].freeze
|
|
117
206
|
|
|
118
207
|
def lookup(collection, name, schema)
|
|
119
208
|
return collection[key(schema, name)] if schema
|
|
120
209
|
|
|
121
210
|
collection[key("temp", name)] || collection[key("main", name)] ||
|
|
122
|
-
collection.find { |(_s, n), _| n == name.to_s.downcase }&.last
|
|
211
|
+
collection.find { |(_s, n), _| n == name.to_s.downcase(:ascii) }&.last
|
|
123
212
|
end
|
|
124
213
|
|
|
125
214
|
def schema_of(name, statement) = name.schema || (statement.temporary ? "temp" : "main")
|
|
@@ -135,7 +224,9 @@ module Plume
|
|
|
135
224
|
end
|
|
136
225
|
|
|
137
226
|
columns = statement.columns.map(&:name)
|
|
138
|
-
|
|
227
|
+
generated = statement.columns.select { |c| c.constraints&.any?(GeneratedAsColumnConstraint) }.map(&:name)
|
|
228
|
+
add_table(name, columns, schema:, generated_columns: generated, rowid_alias: rowid_alias(statement), without_rowid: statement.without_row_id)
|
|
229
|
+
Catalog.autoindexes(statement).each { |index| add_index(index, name, schema:) }
|
|
139
230
|
end
|
|
140
231
|
|
|
141
232
|
def add_create_view(statement)
|
|
@@ -155,12 +246,12 @@ module Plume
|
|
|
155
246
|
unless candidate
|
|
156
247
|
table_pk = statement.constraints&.find { |c| PrimaryKeyTableConstraint === c }
|
|
157
248
|
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 }
|
|
249
|
+
pk_name = table_pk.columns[0].name.downcase(:ascii)
|
|
250
|
+
candidate = statement.columns.find { |c| c.name.downcase(:ascii) == pk_name }
|
|
160
251
|
end
|
|
161
252
|
end
|
|
162
253
|
|
|
163
|
-
candidate.name if candidate&.type&.text
|
|
254
|
+
candidate.name if Catalog.same_name?(candidate&.type&.text, "INTEGER")
|
|
164
255
|
end
|
|
165
256
|
|
|
166
257
|
def add_sqlite_table(db, schema, name, sql)
|
|
@@ -168,11 +259,16 @@ module Plume
|
|
|
168
259
|
# hidden is 0 for ordinary columns, 1 for hidden virtual table columns, 2 and 3 for generated ones
|
|
169
260
|
columns = info.reject { |row| row[6] == 1 }.map { |row| row[1] }
|
|
170
261
|
hidden = info.select { |row| row[6] == 1 }.map { |row| row[1] }
|
|
262
|
+
generated = info.select { |row| row[6] == 2 || row[6] == 3 }.map { |row| row[1] }
|
|
171
263
|
without_rowid = sql.to_s.match?(/\bWITHOUT\s+ROWID\s*;?\s*\z/i)
|
|
172
264
|
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
|
|
265
|
+
rowid_alias = pk_rows[0][1] if !without_rowid && pk_rows.size == 1 && Catalog.same_name?(pk_rows[0][2].to_s, "INTEGER")
|
|
174
266
|
|
|
175
|
-
|
|
267
|
+
statement = Parser.new(sql).parse.first
|
|
268
|
+
module_name = statement.module_name if CreateVirtualTableStatement === statement
|
|
269
|
+
add_table(name, columns, schema:, hidden_columns: hidden, generated_columns: generated, rowid_alias:, without_rowid:, module_name:)
|
|
270
|
+
# The schema has no row for a WITHOUT ROWID table's primary key, though SQLite counts it as an index
|
|
271
|
+
Catalog.autoindexes(statement).each { |index| add_index(index, name, schema:) } if CreateTableStatement === statement
|
|
176
272
|
end
|
|
177
273
|
|
|
178
274
|
def quote(identifier) = %("#{identifier.to_s.gsub('"', '""')}")
|