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.
@@ -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
- NULL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL ILLEGAL
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
- :SPACE # IMP: R-22934-2513
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
- :SPACE # comments are encoded as SPACE tokens [IMP: R-22934-2513]
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
- if peek == 46 # "."
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 == 0xFE && peek(1) == 0xFF
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