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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Plume
|
|
6
|
+
# A comment in SQL, `-- like this` or `/* like this */`, with the byte range it takes up
|
|
7
|
+
Comment = Data.define(:text, :location) do
|
|
8
|
+
def block? = text.start_with?("/*")
|
|
9
|
+
|
|
10
|
+
# What is between the markers
|
|
11
|
+
def body
|
|
12
|
+
block? ? text.delete_prefix("/*").delete_suffix("*/") : text.delete_prefix("--")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The tags of a sqlcommenter comment - `/*controller='posts',action='index'*/`, which Rails'
|
|
16
|
+
# query_log_tags writes too - or of its older `key:value` format. Empty for any other comment
|
|
17
|
+
def tags
|
|
18
|
+
parts = body.strip.split(/,(?=[\w.-]+(?:=|:))/)
|
|
19
|
+
return {} if parts.empty?
|
|
20
|
+
|
|
21
|
+
if parts.all? { |part| part.match?(/\A[\w.%-]+='.*'\z/m) }
|
|
22
|
+
parts.to_h do |part|
|
|
23
|
+
key, value = part.split("=", 2)
|
|
24
|
+
[URI.decode_www_form_component(key), URI.decode_www_form_component(value[1..-2].gsub("\\'", "'"))]
|
|
25
|
+
end
|
|
26
|
+
elsif parts.all? { |part| part.match?(/\A[\w.-]+:[^,\s]+\z/) }
|
|
27
|
+
parts.to_h { |part| part.split(":", 2) }
|
|
28
|
+
else
|
|
29
|
+
{}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module Comments
|
|
35
|
+
extend self
|
|
36
|
+
|
|
37
|
+
def in(sql)
|
|
38
|
+
lexer = Lexer.new(sql)
|
|
39
|
+
found = []
|
|
40
|
+
while (token = lexer.next_token)
|
|
41
|
+
found << Comment.new(text: lexer.value, location: [lexer.anchor, lexer.cursor].freeze) if token == :COMMENT
|
|
42
|
+
end
|
|
43
|
+
found
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The comments right before `node` and right after it, with only spaces between them and it
|
|
47
|
+
def around(node)
|
|
48
|
+
source = node.full_source
|
|
49
|
+
loc = node.location
|
|
50
|
+
return [[], []] unless source && loc
|
|
51
|
+
|
|
52
|
+
tokens = Plume.tokenize(source, with_values: true, with_positions: true, skip_spaces: false)
|
|
53
|
+
first = tokens.index { |_, _, beg, _| beg >= loc[0] }
|
|
54
|
+
last = tokens.rindex { |_, _, _, fin| fin <= loc[1] }
|
|
55
|
+
[gather(tokens, first - 1, -1), gather(tokens, last + 1, 1)]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def gather(tokens, from, step)
|
|
61
|
+
found = []
|
|
62
|
+
i = from
|
|
63
|
+
while i >= 0 && (token = tokens[i])
|
|
64
|
+
type, text, beg, fin = token
|
|
65
|
+
break unless type == :SPACE || type == :COMMENT
|
|
66
|
+
|
|
67
|
+
found << Comment.new(text:, location: [beg, fin].freeze) if type == :COMMENT
|
|
68
|
+
i += step
|
|
69
|
+
end
|
|
70
|
+
(step.negative? ? found.reverse : found)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/plume/dump.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# Trees to and from plain hashes that JSON can carry, and back exactly: positions, keywords as
|
|
5
|
+
# written and the source they point into all come through, so `location`, `source_text` and
|
|
6
|
+
# `to_sql` answer as before. A node is its class under "node" and its props under their names,
|
|
7
|
+
# a token is `[type, beg, fin, kind]`, and the source is kept once, at the root.
|
|
8
|
+
# JSON has no symbols, binary strings or infinity, so those go out tagged: {"symbol" => "EQUALS"},
|
|
9
|
+
# {"bytes" => "9e2e", "encoding" => "BINARY"} and {"float" => "Infinity"}
|
|
10
|
+
module Dump
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
TOKEN_PROPS = %i[leading trailing].freeze
|
|
14
|
+
SENTINELS = { "LiteralFalse" => LiteralFalse }.freeze
|
|
15
|
+
FLOATS = { "Infinity" => Float::INFINITY, "-Infinity" => -Float::INFINITY, "NaN" => Float::NAN }.freeze
|
|
16
|
+
|
|
17
|
+
# Built without recursing, like `walk`, so the deepest tree SQLite takes can be dumped.
|
|
18
|
+
# Generating JSON that deep needs `max_nesting: false`
|
|
19
|
+
def dump(node)
|
|
20
|
+
return node.map { |n| dump(n) } if Array === node
|
|
21
|
+
|
|
22
|
+
hashes = {}.compare_by_identity
|
|
23
|
+
parents = {}.compare_by_identity
|
|
24
|
+
node.walk do |n, up|
|
|
25
|
+
hashes[n] = { "node" => n.class.name.delete_prefix("Plume::") }
|
|
26
|
+
parents[n] = up
|
|
27
|
+
end
|
|
28
|
+
hashes.each do |n, hash|
|
|
29
|
+
n.class.literal_properties.each do |property|
|
|
30
|
+
name = property.name
|
|
31
|
+
value = n.instance_variable_get(:"@#{name}")
|
|
32
|
+
next if LiteralNil.equal?(value)
|
|
33
|
+
# A node shares its parent's source unless it was built by hand or brought in from elsewhere
|
|
34
|
+
next if name == :full_source && !n.equal?(node) && value == parents[n].full_source
|
|
35
|
+
|
|
36
|
+
hash[name.name] =
|
|
37
|
+
if token?(n.class, name) then dump_token(value)
|
|
38
|
+
elsif Node === value then hashes[value]
|
|
39
|
+
elsif Array === value then value.map { |v| (Node === v) ? hashes[v] : dump_value(v) }
|
|
40
|
+
else
|
|
41
|
+
dump_value(value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
hashes[node]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load(hash)
|
|
49
|
+
return hash.map { |h| load(h) } if Array === hash
|
|
50
|
+
|
|
51
|
+
# Parents before children, so building them backwards has every child ready
|
|
52
|
+
order = []
|
|
53
|
+
stack = [[hash, nil]]
|
|
54
|
+
while (h, source = stack.pop)
|
|
55
|
+
source = load_value(h["full_source"]) if h.key?("full_source")
|
|
56
|
+
order << [h, source]
|
|
57
|
+
h.each_value do |v|
|
|
58
|
+
if Hash === v && v.key?("node") then stack << [v, source]
|
|
59
|
+
elsif Array === v then v.each { |e| stack << [e, source] if Hash === e && e.key?("node") }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
built = {}.compare_by_identity
|
|
65
|
+
order.reverse_each { |h, source| built[h] = load_node(h, source, built) }
|
|
66
|
+
built[hash]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def token?(klass, name) = TOKEN_PROPS.include?(name) || klass.token_names.include?(name)
|
|
72
|
+
|
|
73
|
+
def dump_token(tuple)
|
|
74
|
+
return nil if tuple.nil?
|
|
75
|
+
|
|
76
|
+
type, beg, fin, kind = tuple
|
|
77
|
+
[(Array === type) ? type.map(&:name) : type.name, beg, fin, kind.name]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load_token(array)
|
|
81
|
+
return nil if array.nil?
|
|
82
|
+
|
|
83
|
+
type, beg, fin, kind = array
|
|
84
|
+
[(Array === type) ? type.map(&:to_sym) : type.to_sym, beg, fin, kind.to_sym]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dump_value(value)
|
|
88
|
+
case value
|
|
89
|
+
when Symbol then { "symbol" => value.name }
|
|
90
|
+
when String
|
|
91
|
+
if value.encoding == Encoding::BINARY || !value.valid_encoding?
|
|
92
|
+
{ "bytes" => value.unpack1("H*"), "encoding" => value.encoding.name }
|
|
93
|
+
else
|
|
94
|
+
value
|
|
95
|
+
end
|
|
96
|
+
when Float then value.finite? ? value : { "float" => value.to_s }
|
|
97
|
+
when Integer, true, false, nil then value
|
|
98
|
+
when Array then value.map { |v| dump_value(v) }
|
|
99
|
+
else
|
|
100
|
+
name = SENTINELS.key(value) or raise ArgumentError, "cannot dump #{value.inspect}"
|
|
101
|
+
{ "sentinel" => name }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def load_value(value)
|
|
106
|
+
case value
|
|
107
|
+
when Array then value.map { |v| load_value(v) }
|
|
108
|
+
when Hash
|
|
109
|
+
if value.key?("symbol") then value["symbol"].to_sym
|
|
110
|
+
elsif value.key?("bytes") then [value["bytes"]].pack("H*").force_encoding(value["encoding"])
|
|
111
|
+
elsif value.key?("float") then FLOATS.fetch(value["float"])
|
|
112
|
+
elsif value.key?("sentinel") then SENTINELS.fetch(value["sentinel"])
|
|
113
|
+
else
|
|
114
|
+
raise ArgumentError, "cannot load #{value.inspect}"
|
|
115
|
+
end
|
|
116
|
+
else value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def load_node(hash, source, built)
|
|
121
|
+
name = hash.fetch("node")
|
|
122
|
+
klass = Plume.const_get(name, false) if name.match?(/\A[A-Z]\w*\z/) && Plume.const_defined?(name, false)
|
|
123
|
+
raise ArgumentError, "#{name.inspect} is not a node" unless Class === klass && klass <= Node
|
|
124
|
+
|
|
125
|
+
props = { full_source: source }
|
|
126
|
+
hash.each do |key, value|
|
|
127
|
+
next if key == "node" || key == "full_source"
|
|
128
|
+
|
|
129
|
+
name = key.to_sym
|
|
130
|
+
props[name] =
|
|
131
|
+
if token?(klass, name) then load_token(value)
|
|
132
|
+
elsif Hash === value && value.key?("node") then built.fetch(value)
|
|
133
|
+
elsif Array === value then value.map { |v| (Hash === v && v.key?("node")) ? built.fetch(v) : load_value(v) }
|
|
134
|
+
else
|
|
135
|
+
load_value(value)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
node = klass.allocate
|
|
139
|
+
node.__send__(:initialize, **props)
|
|
140
|
+
node
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Plume
|
|
6
|
+
# A short, stable hash of what statements mean, which stays the same across the values in them
|
|
7
|
+
# and how they are spelled: constants (as `Normalize` finds them) and parameters all count as one
|
|
8
|
+
# `?`, an IN list or VALUES of nothing but those counts as one item or row, and token positions,
|
|
9
|
+
# keyword and name case and redundant parentheses do not count at all. It is the same in every
|
|
10
|
+
# process and Ruby, unlike `Object#hash`
|
|
11
|
+
module Fingerprint
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
SKIPPED = %i[full_source leading trailing].freeze
|
|
15
|
+
Field = Data.define(:name)
|
|
16
|
+
|
|
17
|
+
def of(statements)
|
|
18
|
+
Digest::SHA256.hexdigest(stream(Array(statements)).join)[0, 16]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# The tree walked in order without recursing, each item length-prefixed so none runs into the next
|
|
24
|
+
def stream(statements)
|
|
25
|
+
constants = Set.new.compare_by_identity
|
|
26
|
+
statements.each { |s| constants.merge(Normalize.constants(s)) }
|
|
27
|
+
fields = Hash.new { |h, klass| h[klass] = (klass.literal_properties.map(&:name) - klass.token_names.to_a - SKIPPED) | klass.all_inspectable_props.to_a }
|
|
28
|
+
|
|
29
|
+
out = []
|
|
30
|
+
emit = -> (tag, text) { out << "#{tag}#{text.bytesize}:#{text}" }
|
|
31
|
+
pending = statements.reverse.map { |s| [s, nil] }
|
|
32
|
+
while (item, field = pending.pop)
|
|
33
|
+
case item
|
|
34
|
+
when Variable then emit.("?", "")
|
|
35
|
+
when Node
|
|
36
|
+
next emit.("?", "") if constants.include?(item)
|
|
37
|
+
|
|
38
|
+
if ParentheticalExpression === item && Node === item.contents
|
|
39
|
+
pending << [item.contents, field]
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
emit.("n", item.class.name)
|
|
43
|
+
fields[item.class].reverse_each do |name|
|
|
44
|
+
value = node_field(item, name)
|
|
45
|
+
value = collapse(value, item, name, constants)
|
|
46
|
+
pending << [value, name] << [Field.new(name), nil]
|
|
47
|
+
end
|
|
48
|
+
when Array
|
|
49
|
+
emit.("l", item.size.to_s)
|
|
50
|
+
item.reverse_each { |element| pending << [element, field] }
|
|
51
|
+
when Field then emit.("f", item.name.name)
|
|
52
|
+
when String
|
|
53
|
+
# Names are matched without regard to ASCII case, the values a statement keeps (a DEFAULT) are not
|
|
54
|
+
emit.("s", (field == :value) ? item.b : item.b.downcase(:ascii))
|
|
55
|
+
when Symbol then emit.("y", item.name.upcase)
|
|
56
|
+
when LiteralNil, nil then emit.("0", "")
|
|
57
|
+
else emit.("v", item.inspect)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
out
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def node_field(node, name)
|
|
64
|
+
if ParentheticalExpression === node && name == :value
|
|
65
|
+
node.contents
|
|
66
|
+
else
|
|
67
|
+
node.respond_to?(name) ? node.public_send(name) : node.instance_variable_get(:"@#{name}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Only the shape of a list of values counts, not how many values it has
|
|
72
|
+
def collapse(value, node, name, constants)
|
|
73
|
+
return value unless Array === value && value.size > 1
|
|
74
|
+
|
|
75
|
+
if ParentheticalExpression === node && name == :value && value.all? { |v| placeholder?(v, constants) }
|
|
76
|
+
value.first(1)
|
|
77
|
+
elsif ValuesClause === node && name == :rows && value.all? { |row| row.values.all? { |v| placeholder?(v, constants) } }
|
|
78
|
+
value.uniq { |row| row.values.size }
|
|
79
|
+
else
|
|
80
|
+
value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def placeholder?(node, constants) = Variable === node || constants.include?(node)
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/plume/lexer.rb
CHANGED
|
@@ -201,7 +201,7 @@ module Plume
|
|
|
201
201
|
# Maps ASCII characters to their respective character types
|
|
202
202
|
# Each element's index maps to the byte value of that character
|
|
203
203
|
CHARACTER_TYPES = %i[
|
|
204
|
-
|
|
204
|
+
NUL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL
|
|
205
205
|
ILLEGAL SPACE SPACE ILLEGAL SPACE SPACE ILLEGAL ILLEGAL
|
|
206
206
|
ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL
|
|
207
207
|
ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL
|
|
@@ -297,7 +297,7 @@ module Plume
|
|
|
297
297
|
it[44] = :COMMA # ,
|
|
298
298
|
it[59] = :SEMI # ;
|
|
299
299
|
it[126] = :BITNOT # ~
|
|
300
|
-
end
|
|
300
|
+
end.freeze
|
|
301
301
|
|
|
302
302
|
attr_reader :sql
|
|
303
303
|
attr_accessor :cursor, :anchor
|
|
@@ -316,7 +316,7 @@ module Plume
|
|
|
316
316
|
def next_token
|
|
317
317
|
@anchor = @cursor
|
|
318
318
|
token = lex_next_token
|
|
319
|
-
return next_token if token == :SPACE && @skip_spaces
|
|
319
|
+
return next_token if (token == :SPACE || token == :COMMENT) && @skip_spaces
|
|
320
320
|
token
|
|
321
321
|
end
|
|
322
322
|
|
|
@@ -352,7 +352,7 @@ module Plume
|
|
|
352
352
|
step
|
|
353
353
|
step until (b = peek).nil? || b == 10 # "\n"
|
|
354
354
|
|
|
355
|
-
:
|
|
355
|
+
:COMMENT # which SQLite takes for a space [IMP: R-22934-2513]
|
|
356
356
|
elsif peek == 62 # ">"
|
|
357
357
|
# differentiate between different pointers
|
|
358
358
|
if peek(1) == 62 # ">"
|
|
@@ -373,7 +373,7 @@ module Plume
|
|
|
373
373
|
step until (b = peek).nil? || (b == 42 && peek(1) == 47) # consume until EOF or "*/"
|
|
374
374
|
step(2) if peek == 42 && peek(1) == 47 # consume the "*/"
|
|
375
375
|
|
|
376
|
-
:
|
|
376
|
+
:COMMENT # which SQLite takes for a space [IMP: R-22934-2513]
|
|
377
377
|
when :EQ
|
|
378
378
|
step if peek == 61 # "="
|
|
379
379
|
:EQ
|
|
@@ -460,7 +460,8 @@ module Plume
|
|
|
460
460
|
end
|
|
461
461
|
end
|
|
462
462
|
|
|
463
|
-
|
|
463
|
+
# A number that began with its "." has had its fraction already, ".5.5" is two tokens
|
|
464
|
+
if character_type != :DOT && peek == 46 # "."
|
|
464
465
|
step
|
|
465
466
|
token_type = :FLOAT if token_type == :INTEGER
|
|
466
467
|
|
|
@@ -604,7 +605,7 @@ module Plume
|
|
|
604
605
|
|
|
605
606
|
:ID
|
|
606
607
|
when :BOM
|
|
607
|
-
return step(2) && :SPACE if peek ==
|
|
608
|
+
return step(2) && :SPACE if peek == 0xBB && peek(1) == 0xBF
|
|
608
609
|
|
|
609
610
|
step while id?(peek)
|
|
610
611
|
:ID
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# Stand-ins for what yippee-fun/literal#391 fixes in Literal itself, so that Plume can run
|
|
5
|
+
# in Ractors before that is released. They apply to Plume's own classes only, and each one
|
|
6
|
+
# stays off when the installed Literal does not need it. Once Plume requires a Literal
|
|
7
|
+
# with that fix, delete this file, its require and extend in plume.rb and its prepend in Node
|
|
8
|
+
module LiteralPatches
|
|
9
|
+
# A Mutex can never be shared between Ractors, and Literal keeps one in every schema
|
|
10
|
+
SCHEMA_HOLDS_MUTEX = Literal::Properties::Schema.new.instance_variable_defined?(:@mutex)
|
|
11
|
+
|
|
12
|
+
# Literal 1.9.0 calls the block of a `_Deferred` on every check. Unreleased Literal caches
|
|
13
|
+
# the result instead, but writes to the type on every check while doing so - and a frozen
|
|
14
|
+
# one then raises FrozenError, materialized or not
|
|
15
|
+
DEFERRED_WRITES_ON_CHECK = begin
|
|
16
|
+
probe = Literal::Types::DeferredType.new { Integer }
|
|
17
|
+
probe === 1
|
|
18
|
+
probe.freeze === 1
|
|
19
|
+
false
|
|
20
|
+
rescue FrozenError
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A frozen schema with the same readers, which `make_shareable!` swaps in once the props
|
|
25
|
+
# of a node class are all declared
|
|
26
|
+
class ShareableSchema
|
|
27
|
+
include Enumerable
|
|
28
|
+
|
|
29
|
+
attr_reader :properties_index
|
|
30
|
+
|
|
31
|
+
def initialize(schema)
|
|
32
|
+
@properties_index = schema.properties_index.dup
|
|
33
|
+
@sorted_properties = schema.to_a
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def [](key) = @properties_index[key]
|
|
37
|
+
def each(&) = @sorted_properties.each(&)
|
|
38
|
+
def size = @sorted_properties.size
|
|
39
|
+
def empty? = @sorted_properties.empty?
|
|
40
|
+
|
|
41
|
+
# Literal starts the schema of a subclass from a dup of its parent's
|
|
42
|
+
def dup = Literal::Properties::Schema.new(properties_index: @properties_index.dup, sorted_properties: @sorted_properties.dup)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
module NodeSchema
|
|
46
|
+
def literal_properties = @shareable_literal_properties || super
|
|
47
|
+
|
|
48
|
+
def make_shareable!
|
|
49
|
+
@shareable_literal_properties = Plume.make_shareable(ShareableSchema.new(literal_properties))
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Only ever used when DEFERRED_WRITES_ON_CHECK, so this can rely on the caching version's ivars
|
|
55
|
+
class Deferred < Literal::Types::DeferredType
|
|
56
|
+
def materialize = frozen? ? frozen_type : super
|
|
57
|
+
|
|
58
|
+
# JRuby takes an endless operator ending in "=" for a setter
|
|
59
|
+
def ===(other)
|
|
60
|
+
frozen? ? frozen_type === other : super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def >=(other, context: nil)
|
|
64
|
+
frozen? ? Literal.subtype?(other, frozen_type, context:) : super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def frozen_type = UNMATERIALIZED.equal?(@materialized) ? @block.call : @materialized
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module Types
|
|
73
|
+
def _Deferred(&) = Deferred.new(&)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# Constants turned into `?` parameters, so the same query with different values comes out the
|
|
5
|
+
# same - for grouping a log the way pg_stat_statements does, or for keeping values out of it.
|
|
6
|
+
# Numbers, strings and blobs are replaced, a sign in front of a number with them. NULL, TRUE,
|
|
7
|
+
# FALSE and CURRENT_* are not, since `IS ?` would not mean `IS NULL`. Neither is an integer
|
|
8
|
+
# standing alone in ORDER BY or GROUP BY, which SQLite takes for a column's position.
|
|
9
|
+
# Only SELECT, INSERT, UPDATE and DELETE (and EXPLAIN of them) are normalized, other statements
|
|
10
|
+
# are left as they were: their constants are schema, not data
|
|
11
|
+
module Normalize
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
STATEMENTS = [SelectStatement, InsertStatement, UpdateStatement, DeleteStatement].freeze
|
|
15
|
+
|
|
16
|
+
# The SQL with each constant swapped for `?` in place, everything else - spacing, case,
|
|
17
|
+
# comments - kept as written
|
|
18
|
+
def text(sql)
|
|
19
|
+
src = sql.b
|
|
20
|
+
spans = Parser.new(sql).parse.flat_map { |statement| constants(statement).map(&:location) }
|
|
21
|
+
out = +""
|
|
22
|
+
at = 0
|
|
23
|
+
spans.sort_by(&:first).each do |beg, fin|
|
|
24
|
+
out << src.byteslice(at, beg - at) << "?"
|
|
25
|
+
at = fin
|
|
26
|
+
end
|
|
27
|
+
out << src.byteslice(at, src.bytesize - at)
|
|
28
|
+
out.force_encoding(sql.encoding)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A copy of the tree with each constant replaced by a `?` Variable
|
|
32
|
+
def tree(node)
|
|
33
|
+
targets = Set.new(constants(node)).compare_by_identity
|
|
34
|
+
node.transform { |n| targets.include?(n) ? Variable.new(name: "?") : n }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The nodes to replace: literals, or a sign with the literal under it
|
|
38
|
+
def constants(statement)
|
|
39
|
+
root = (ExplainStatement === statement) ? statement.statement : statement
|
|
40
|
+
return [] unless STATEMENTS.any? { |klass| klass === root }
|
|
41
|
+
|
|
42
|
+
found = []
|
|
43
|
+
# By identity: hashing a node hashes its whole subtree
|
|
44
|
+
positional = Set.new.compare_by_identity
|
|
45
|
+
root.walk_with_slots do |node, parent, prop|
|
|
46
|
+
case node
|
|
47
|
+
when OrderingTerm
|
|
48
|
+
positional << node if SelectStatement === parent && prop == :orderings
|
|
49
|
+
when UnaryExpression
|
|
50
|
+
found << node if (node.operator in :NEGATE | :IDENTITY) && value?(node.operand)
|
|
51
|
+
when LiteralExpression
|
|
52
|
+
next if UnaryExpression === parent && (parent.operator in :NEGATE | :IDENTITY)
|
|
53
|
+
next if Integer === node.value && (positional.include?(parent) || (SelectCore === parent && prop == :groupings))
|
|
54
|
+
|
|
55
|
+
found << node if value?(node)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
found
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def value?(node) = LiteralExpression === node && (node.value in Numeric | String)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# The bind parameters of one statement, numbered the way SQLite numbers them: `?` takes the
|
|
5
|
+
# next number, `?N` takes N, and a name keeps the number it first got. `count` and `names`
|
|
6
|
+
# are what `sqlite3_bind_parameter_count` and `sqlite3_bind_parameter_name` would answer
|
|
7
|
+
class Parameters
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
# One place a parameter appears. `name` is nil for a bare `?`
|
|
11
|
+
Parameter = Data.define(:number, :name, :node)
|
|
12
|
+
|
|
13
|
+
attr_reader :count, :names
|
|
14
|
+
|
|
15
|
+
def initialize(statement)
|
|
16
|
+
slots = {}
|
|
17
|
+
@count = 0
|
|
18
|
+
@parameters = variables(statement).map do |variable|
|
|
19
|
+
name = variable.name
|
|
20
|
+
number =
|
|
21
|
+
if name == "?"
|
|
22
|
+
@count += 1
|
|
23
|
+
elsif (n = name[/\A\?(\d+)\z/, 1]&.to_i)
|
|
24
|
+
slots[n] ||= name
|
|
25
|
+
@count = n if n > @count
|
|
26
|
+
n
|
|
27
|
+
elsif (n = slots.key(name))
|
|
28
|
+
n
|
|
29
|
+
else
|
|
30
|
+
slots[@count += 1] = name
|
|
31
|
+
@count
|
|
32
|
+
end
|
|
33
|
+
Parameter.new(number:, name: (name == "?") ? nil : name, node: variable)
|
|
34
|
+
end.freeze
|
|
35
|
+
@names = Array.new(@count) { |i| slots[i + 1] }.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def each(&) = @parameters.each(&)
|
|
39
|
+
|
|
40
|
+
def size = @parameters.size
|
|
41
|
+
|
|
42
|
+
# The number a name was given, as `sqlite3_bind_parameter_index` answers it
|
|
43
|
+
def index(name) = @names.index(name)&.+(1)
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# SQLite numbers them as it reads them, which is not always the order of the tree
|
|
48
|
+
def variables(statement)
|
|
49
|
+
statement.each_node.grep(Variable).each_with_index.sort_by { |v, i| [v.location&.first || 0, i] }.map(&:first)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|