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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# The printer laid out over lines: each clause starts a line, a clause too long for
|
|
5
|
+
# `max_line_length` puts its items one per line, indented by `indent_size`, and a long AND or OR
|
|
6
|
+
# chain in a condition puts each operand on a line of its own. Subqueries are indented inside
|
|
7
|
+
# their parentheses. Expressions otherwise print as `Printer` prints them, so what comes out
|
|
8
|
+
# parses into the same tree
|
|
9
|
+
class PrettyPrinter < Printer
|
|
10
|
+
def self.print(node, indent_size: 4, max_line_length: 80, commas_start_of_line: false)
|
|
11
|
+
new(indent_size:, max_line_length:, commas_start_of_line:).print(node)
|
|
12
|
+
rescue *STACK_EXHAUSTED
|
|
13
|
+
raise Error, "statement nested too deeply to print"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(indent_size:, max_line_length:, commas_start_of_line:)
|
|
17
|
+
super()
|
|
18
|
+
@pad = " " * indent_size
|
|
19
|
+
@max = max_line_length
|
|
20
|
+
@leading_commas = commas_start_of_line
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def lines(*parts) = parts.flatten.reject { |p| !p || p.empty? }.join("\n")
|
|
26
|
+
|
|
27
|
+
# Only the line breaks between tokens: one inside a string or quoted name is part of its value
|
|
28
|
+
def indent(text)
|
|
29
|
+
src = text.b
|
|
30
|
+
breaks = []
|
|
31
|
+
lexer = Lexer.new(text)
|
|
32
|
+
while (token = lexer.next_token)
|
|
33
|
+
next unless token == :SPACE
|
|
34
|
+
|
|
35
|
+
((lexer.anchor)...(lexer.cursor)).each { |i| breaks << i if src.getbyte(i) == 10 && i + 1 < src.bytesize && src.getbyte(i + 1) != 10 }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
out = (src.empty? ? +"" : @pad.b.dup)
|
|
39
|
+
at = 0
|
|
40
|
+
breaks.each do |i|
|
|
41
|
+
out << src.byteslice(at, i + 1 - at) << @pad
|
|
42
|
+
at = i + 1
|
|
43
|
+
end
|
|
44
|
+
(out << src.byteslice(at, src.bytesize - at)).force_encoding(text.encoding)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Lines after the first of a multi-line operand line up under the clause it sits in
|
|
48
|
+
def hanging(text) = text.include?("\n") ? indent(text).delete_prefix(@pad) : text
|
|
49
|
+
|
|
50
|
+
def fits?(text) = !text.include?("\n") && text.length <= @max
|
|
51
|
+
|
|
52
|
+
# `KEYWORD a, b, c` on one line if it fits, otherwise the keyword alone and an item per line
|
|
53
|
+
def clause(keyword, items)
|
|
54
|
+
items = Array(items).map { |item| (String === item) ? item : print(item) }
|
|
55
|
+
one_line = "#{keyword} #{items.join(", ")}"
|
|
56
|
+
return one_line if fits?(one_line)
|
|
57
|
+
|
|
58
|
+
body =
|
|
59
|
+
if @leading_commas
|
|
60
|
+
items.each_with_index.map { |item, i| i.zero? ? " #{item}" : ", #{item}" }.join("\n")
|
|
61
|
+
else
|
|
62
|
+
items.join(",\n")
|
|
63
|
+
end
|
|
64
|
+
"#{keyword}\n#{indent(body)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A condition on one line if it fits, otherwise its top-level AND or OR chain an operand per line
|
|
68
|
+
def condition(keyword, node)
|
|
69
|
+
one_line = "#{keyword} #{print(node)}"
|
|
70
|
+
return one_line if fits?(one_line) || !(BinaryExpression === node && (node.operator in :ALL | :ANY))
|
|
71
|
+
|
|
72
|
+
chain = [node]
|
|
73
|
+
chain << chain.last.left while BinaryExpression === chain.last.left && chain.last.left.operator == node.operator
|
|
74
|
+
word = BINARY_OPERATORS.fetch(node.operator)
|
|
75
|
+
rest = chain.reverse_each.map { |link| "#{word} #{operand(link.right)}" }
|
|
76
|
+
lines("#{keyword} #{hanging(operand(chain.last.left))}", rest.map { |line| indent(line) })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parens(inner) = inner.include?("\n") ? "(\n#{indent(inner)}\n)" : "(#{inner})"
|
|
80
|
+
|
|
81
|
+
def pretty_order_and_limit(node)
|
|
82
|
+
limit =
|
|
83
|
+
if node.offset && node.offset_tk_tok == :COMMA
|
|
84
|
+
"LIMIT #{print(node.offset)}, #{print(node.limit)}"
|
|
85
|
+
else
|
|
86
|
+
join(node.limit && "LIMIT #{print(node.limit)}", node.offset && "OFFSET #{print(node.offset)}")
|
|
87
|
+
end
|
|
88
|
+
[node.orderings && clause("ORDER BY", node.orderings), limit]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# -- statements
|
|
92
|
+
|
|
93
|
+
def print_select_statement(node) = lines(print(node.with_clause), print(node.body), pretty_order_and_limit(node))
|
|
94
|
+
|
|
95
|
+
def print_with_clause(node)
|
|
96
|
+
ctes = node.ctes.map { |cte| print(cte) }
|
|
97
|
+
head = join("WITH", node.recursive && "RECURSIVE")
|
|
98
|
+
"#{head} #{ctes.join(",\n")}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def print_common_table_expression(node)
|
|
102
|
+
materialized = { true => "MATERIALIZED", false => "NOT MATERIALIZED" }[node.materialized]
|
|
103
|
+
name = identifier(node.name) + (node.columns ? "(#{list(node.columns)})" : "")
|
|
104
|
+
join(name, "AS", materialized, parens(print(node.select)))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def print_compound_select(node) = lines(print(node.left), COMPOUND_OPERATORS.fetch(node.operator), print(node.right))
|
|
108
|
+
|
|
109
|
+
def print_select_core(node)
|
|
110
|
+
lines(
|
|
111
|
+
clause(node.distinct ? "SELECT DISTINCT" : "SELECT", node.columns),
|
|
112
|
+
node.source && from(node.source),
|
|
113
|
+
node.condition && condition("WHERE", node.condition),
|
|
114
|
+
node.groupings && clause("GROUP BY", node.groupings),
|
|
115
|
+
node.having && condition("HAVING", node.having),
|
|
116
|
+
node.windows && clause("WINDOW", node.windows),
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def from(source) = (JoinClause === source) ? "FROM #{print(source)}" : clause("FROM", [source])
|
|
121
|
+
|
|
122
|
+
def print_values_clause(node) = clause("VALUES", node.rows)
|
|
123
|
+
|
|
124
|
+
def print_join_clause(node)
|
|
125
|
+
node.joins.reduce(print(node.left)) do |sql, each|
|
|
126
|
+
operator = print(each.operator)
|
|
127
|
+
right = join(print(each.source), print(each.constraint))
|
|
128
|
+
(operator == ",") ? "#{sql},\n#{right}" : "#{sql}\n#{operator} #{right}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def print_subquery_source(node) = join(parens(print(node.select)), alias_of(node))
|
|
133
|
+
def print_subquery_expression(node) = parens(print(node.select))
|
|
134
|
+
def print_exists_expression(node) = "EXISTS #{parens(print(node.select))}"
|
|
135
|
+
|
|
136
|
+
def print_insert_statement(node)
|
|
137
|
+
verb = node.or_action ? ((node.insert_kw_tok == :REPLACE) ? "REPLACE" : "INSERT OR #{node.or_action}") : "INSERT"
|
|
138
|
+
lines(
|
|
139
|
+
print(node.with_clause),
|
|
140
|
+
join(verb, "INTO", print(node.table), node.columns && "(#{list(node.columns)})"),
|
|
141
|
+
node.default_values ? "DEFAULT VALUES" : print(node.select),
|
|
142
|
+
node.upserts&.map { |u| print(u) },
|
|
143
|
+
print(node.returning),
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def print_upsert_clause(node)
|
|
148
|
+
target = node.targets && "(#{list(node.targets)})"
|
|
149
|
+
head = join("ON CONFLICT", target, node.target_condition && "WHERE #{print(node.target_condition)}")
|
|
150
|
+
return join(head, "DO NOTHING") if node.action == :NOTHING
|
|
151
|
+
|
|
152
|
+
lines(head, indent(lines(clause("DO UPDATE SET", node.assignments), node.condition && condition("WHERE", node.condition))))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def print_update_statement(node)
|
|
156
|
+
lines(
|
|
157
|
+
print(node.with_clause),
|
|
158
|
+
join(node.or_action ? "UPDATE OR #{node.or_action}" : "UPDATE", print(node.table)),
|
|
159
|
+
clause("SET", node.assignments),
|
|
160
|
+
node.source && from(node.source),
|
|
161
|
+
node.condition && condition("WHERE", node.condition),
|
|
162
|
+
print(node.returning), pretty_order_and_limit(node),
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def print_delete_statement(node)
|
|
167
|
+
lines(
|
|
168
|
+
print(node.with_clause), "DELETE FROM #{print(node.table)}",
|
|
169
|
+
node.condition && condition("WHERE", node.condition),
|
|
170
|
+
print(node.returning), pretty_order_and_limit(node),
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def print_returning_clause(node) = clause("RETURNING", node.columns)
|
|
175
|
+
|
|
176
|
+
def print_create_table_statement(node)
|
|
177
|
+
head = join("CREATE", node.temporary && "TEMP", "TABLE", node.if_not_exists && "IF NOT EXISTS", print(node.table))
|
|
178
|
+
return lines("#{head} AS", print(node.select_statement)) if node.select_statement
|
|
179
|
+
|
|
180
|
+
definitions = [*node.columns, *node.constraints].map { |d| print(d) }
|
|
181
|
+
join("#{head} (\n#{indent(definitions.join(",\n"))}\n)", node.options && list(node.options))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def print_create_view_statement(node)
|
|
185
|
+
head = join(
|
|
186
|
+
"CREATE", node.temporary && "TEMP", "VIEW", node.if_not_exists && "IF NOT EXISTS", print(node.view),
|
|
187
|
+
node.columns && "(#{list(node.columns)})", "AS",
|
|
188
|
+
)
|
|
189
|
+
lines(head, print(node.select))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def print_create_trigger_statement(node)
|
|
193
|
+
event = node.columns ? "UPDATE OF #{list(node.columns)}" : node.event.to_s
|
|
194
|
+
head = join(
|
|
195
|
+
"CREATE", node.temporary && "TEMP", "TRIGGER", node.if_not_exists && "IF NOT EXISTS", print(node.trigger),
|
|
196
|
+
node.time&.to_s&.tr("_", " "), event, "ON", print(node.table),
|
|
197
|
+
node.for_each_row_kw? && "FOR EACH ROW", node.condition && "WHEN #{print(node.condition)}",
|
|
198
|
+
)
|
|
199
|
+
lines(head, "BEGIN", node.statements.map { |s| indent("#{print(s)};") }, "END")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
data/lib/plume/printer.rb
CHANGED
|
@@ -4,8 +4,9 @@ module Plume
|
|
|
4
4
|
# Turns an AST back into SQL. It works from the nodes' readers rather than from the tokens
|
|
5
5
|
# they were parsed from, so trees built by hand, or changed after parsing, print too. The
|
|
6
6
|
# output is canonical rather than a copy of the input: keywords come out in upper case,
|
|
7
|
-
# identifiers are quoted only when they have to be, and
|
|
8
|
-
#
|
|
7
|
+
# identifiers are quoted only when they have to be, and a compound operand gets parentheses
|
|
8
|
+
# unless it is the left operand of an operator at its own precedence, where SQLite's
|
|
9
|
+
# left-associativity already groups it.
|
|
9
10
|
class Printer
|
|
10
11
|
BARE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
11
12
|
|
|
@@ -18,6 +19,21 @@ module Plume
|
|
|
18
19
|
NOT_REGEXP: "NOT REGEXP", MATCH: "MATCH", NOT_MATCH: "NOT MATCH", IN: "IN", NOT_IN: "NOT IN",
|
|
19
20
|
}.freeze
|
|
20
21
|
|
|
22
|
+
# Names that would read back as the start of an expression
|
|
23
|
+
EXPRESSION_KEYWORDS = (Parser::LITERAL_KEYWORDS + [:CAST, :RAISE]).freeze
|
|
24
|
+
|
|
25
|
+
# Levels Plume's parser and SQLite agree on: SQLite also puts = and IS with IN, LIKE and the rest
|
|
26
|
+
PRECEDENCE = {
|
|
27
|
+
ANY: 1, ALL: 2,
|
|
28
|
+
IN: 3, NOT_IN: 3, MATCH: 3, NOT_MATCH: 3, GLOB: 3, NOT_GLOB: 3, REGEXP: 3, NOT_REGEXP: 3,
|
|
29
|
+
EQUALS: 4, NOT_EQUALS: 4, IS: 4, IS_NOT: 4,
|
|
30
|
+
BELOW: 5, ABOVE: 5, ATMOST: 5, ATLEAST: 5,
|
|
31
|
+
BIT_AND: 6, BIT_OR: 6, BIT_LSHIFT: 6, BIT_RSHIFT: 6,
|
|
32
|
+
ADD: 7, SUB: 7,
|
|
33
|
+
MULTIPLY: 8, DIVIDE: 8, MODULO: 8,
|
|
34
|
+
CONCAT: 9, EXTRACT: 9, RETRIEVE: 9,
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
21
37
|
COMPOUND_OPERATORS = { UNION: "UNION", UNION_ALL: "UNION ALL", INTERSECT: "INTERSECT", EXCEPT: "EXCEPT" }.freeze
|
|
22
38
|
JOIN_KINDS = { COMMA: ",", INNER: "JOIN", LEFT: "LEFT JOIN", RIGHT: "RIGHT JOIN", FULL: "FULL JOIN", CROSS: "CROSS JOIN" }.freeze
|
|
23
39
|
FOREIGN_KEY_ACTIONS = { CASCADE: "CASCADE", RESTRICT: "RESTRICT", SET_NULL: "SET NULL", SET_DEFAULT: "SET DEFAULT", NO_ACTION: "NO ACTION" }.freeze
|
|
@@ -25,7 +41,14 @@ module Plume
|
|
|
25
41
|
# Operands of these need parentheses to keep their grouping when printed inside another expression
|
|
26
42
|
COMPOUND_EXPRESSIONS = [BinaryExpression, UnaryExpression, TernaryExpression, LikeExpression, CollationExpression].freeze
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
Error = Class.new(StandardError)
|
|
45
|
+
|
|
46
|
+
def self.print(node)
|
|
47
|
+
new.print(node)
|
|
48
|
+
rescue *STACK_EXHAUSTED
|
|
49
|
+
# As with parsing, how deep is too deep depends on the stack the caller has left
|
|
50
|
+
raise Error, "statement nested too deeply to print"
|
|
51
|
+
end
|
|
29
52
|
|
|
30
53
|
def print(node)
|
|
31
54
|
case node
|
|
@@ -40,18 +63,22 @@ module Plume
|
|
|
40
63
|
|
|
41
64
|
def identifier(name)
|
|
42
65
|
name = name.to_s
|
|
43
|
-
return name if name.match?(BARE_IDENTIFIER) && bare_keyword_ok?(name.upcase.to_sym)
|
|
66
|
+
return name if name.ascii_only? && name.match?(BARE_IDENTIFIER) && bare_keyword_ok?(name.upcase.to_sym)
|
|
44
67
|
|
|
45
|
-
%("#{name
|
|
68
|
+
%("#{doubled(name, '"')}")
|
|
46
69
|
end
|
|
47
70
|
|
|
48
|
-
# Keywords SQLite falls back to treating as identifiers, like ROWID or KEY, need no quotes
|
|
49
|
-
|
|
71
|
+
# Keywords SQLite falls back to treating as identifiers, like ROWID or KEY, need no quotes -
|
|
72
|
+
# except the ones that would read back as the start of an expression
|
|
73
|
+
def bare_keyword_ok?(word) = !Lexer::KEYWORDS.include?(word) || (Parser::TOKEN_FALLBACKS.include?(word) && !EXPRESSION_KEYWORDS.include?(word))
|
|
50
74
|
|
|
51
|
-
def string(value) = "'#{value.to_s
|
|
75
|
+
def string(value) = "'#{doubled(value.to_s, "'")}'"
|
|
52
76
|
|
|
53
77
|
private
|
|
54
78
|
|
|
79
|
+
# Done on the bytes, since TruffleRuby's gsub refuses a string that is not valid UTF-8
|
|
80
|
+
def doubled(text, quote) = text.b.gsub(quote, quote * 2).force_encoding(text.encoding)
|
|
81
|
+
|
|
55
82
|
def underscore(name) = name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
56
83
|
|
|
57
84
|
def join(*parts) = parts.flatten.reject { |p| !p || (p.respond_to?(:empty?) && p.empty?) }.join(" ")
|
|
@@ -77,8 +104,12 @@ module Plume
|
|
|
77
104
|
def order_and_limit(node)
|
|
78
105
|
[
|
|
79
106
|
node.orderings && "ORDER BY #{list(node.orderings)}",
|
|
80
|
-
|
|
81
|
-
node.offset &&
|
|
107
|
+
# `LIMIT offset, limit` keeps its order: swapping the two would renumber their `?` parameters
|
|
108
|
+
if node.offset && node.offset_tk_tok == :COMMA
|
|
109
|
+
"LIMIT #{print(node.offset)}, #{print(node.limit)}"
|
|
110
|
+
else
|
|
111
|
+
[node.limit && "LIMIT #{print(node.limit)}", node.offset && "OFFSET #{print(node.offset)}"]
|
|
112
|
+
end,
|
|
82
113
|
]
|
|
83
114
|
end
|
|
84
115
|
|
|
@@ -213,6 +244,10 @@ module Plume
|
|
|
213
244
|
when :RENAME_COLUMN then "RENAME COLUMN #{identifier(node.column)} TO #{identifier(node.new_name)}"
|
|
214
245
|
when :ADD_COLUMN then "ADD COLUMN #{print(node.column_definition)}"
|
|
215
246
|
when :DROP_COLUMN then "DROP COLUMN #{identifier(node.column)}"
|
|
247
|
+
when :ADD_CONSTRAINT then "ADD #{print(node.constraint)}"
|
|
248
|
+
when :DROP_CONSTRAINT then "DROP CONSTRAINT #{identifier(node.constraint_name)}"
|
|
249
|
+
when :SET_NOT_NULL then join("ALTER COLUMN #{identifier(node.column)} SET NOT NULL", print(node.conflict_clause))
|
|
250
|
+
when :DROP_NOT_NULL then "ALTER COLUMN #{identifier(node.column)} DROP NOT NULL"
|
|
216
251
|
end
|
|
217
252
|
join("ALTER TABLE", print(node.table), action)
|
|
218
253
|
end
|
|
@@ -298,8 +333,18 @@ module Plume
|
|
|
298
333
|
|
|
299
334
|
def print_variable(node) = node.name
|
|
300
335
|
|
|
336
|
+
# Every binary operator associates to the left, so a left operand at the same precedence needs no
|
|
337
|
+
# parentheses: `a - b + c`, `a OR b OR c`. A long chain then prints flat rather than nesting a pair
|
|
338
|
+
# per term, and the left spine is walked in a loop, so its length costs no stack
|
|
301
339
|
def print_binary_expression(node)
|
|
302
|
-
|
|
340
|
+
chain = [node]
|
|
341
|
+
while BinaryExpression === (left = chain.last.left) && PRECEDENCE[left.operator] == PRECEDENCE[node.operator]
|
|
342
|
+
chain << left
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
chain.reverse_each.reduce(+operand(chain.last.left)) do |sql, link|
|
|
346
|
+
sql << " " << BINARY_OPERATORS.fetch(link.operator) << " " << operand(link.right)
|
|
347
|
+
end
|
|
303
348
|
end
|
|
304
349
|
|
|
305
350
|
def print_unary_expression(node)
|
|
@@ -334,7 +379,7 @@ module Plume
|
|
|
334
379
|
|
|
335
380
|
def print_parenthetical_expression(node) = "(#{print(node.contents)})"
|
|
336
381
|
|
|
337
|
-
def print_cast_expression(node) = "CAST(#{print(node.expression)
|
|
382
|
+
def print_cast_expression(node) = "CAST(#{join(print(node.expression), "AS", print(node.as))})"
|
|
338
383
|
|
|
339
384
|
def print_case_expression(node)
|
|
340
385
|
join("CASE", node.predicate && print(node.predicate), list_of(node.conditions), node.else_clause && "ELSE #{print(node.else_clause)}", "END")
|
|
@@ -354,7 +399,8 @@ module Plume
|
|
|
354
399
|
end
|
|
355
400
|
|
|
356
401
|
def print_function_arguments(node)
|
|
357
|
-
|
|
402
|
+
quantifier = node.distinct ? "DISTINCT" : (node.distinct_kw_tok == :ALL && "ALL")
|
|
403
|
+
join(quantifier, list(node.expressions), node.order_by && "ORDER BY #{list(node.order_by)}")
|
|
358
404
|
end
|
|
359
405
|
|
|
360
406
|
def print_star_function_argument(_node) = "*"
|
|
@@ -442,19 +488,27 @@ module Plume
|
|
|
442
488
|
end
|
|
443
489
|
|
|
444
490
|
def print_unique_table_constraint(node) = join(constraint_name(node), "UNIQUE (#{list(node.columns)})", print(node.conflict_clause))
|
|
445
|
-
def print_check_table_constraint(node) = join(constraint_name(node), "CHECK (#{print(node.expression)})")
|
|
491
|
+
def print_check_table_constraint(node) = join(constraint_name(node), "CHECK (#{print(node.expression)})", print(node.conflict_clause))
|
|
446
492
|
def print_no_op_table_constraint(node) = constraint_name(node)
|
|
447
493
|
|
|
494
|
+
def print_deferrable_column_constraint(node) = join(constraint_name(node), deferrable(node) || "DEFERRABLE")
|
|
495
|
+
|
|
496
|
+
# The words as written, or else what `deferred` says
|
|
497
|
+
def deferrable(node)
|
|
498
|
+
return Array(node.deferrable_span_tok).join(" ") if node.deferrable_span?
|
|
499
|
+
|
|
500
|
+
"DEFERRABLE INITIALLY DEFERRED" if node.deferred
|
|
501
|
+
end
|
|
502
|
+
|
|
448
503
|
def print_foreign_key_table_constraint(node)
|
|
449
504
|
join(constraint_name(node), "FOREIGN KEY (#{list(node.columns)})", print(node.foreign_key_clause))
|
|
450
505
|
end
|
|
451
506
|
|
|
452
507
|
def print_foreign_key_clause(node)
|
|
453
|
-
deferrable = node.deferrable_span? ? node.deferrable_span_tok : (node.deferred && "DEFERRABLE INITIALLY DEFERRED")
|
|
454
508
|
join(
|
|
455
509
|
"REFERENCES", identifier(node.table), node.columns && "(#{list(node.columns)})",
|
|
456
510
|
list_of(Array(node.actions)), list_of(Array(node.match_clauses)),
|
|
457
|
-
deferrable
|
|
511
|
+
deferrable(node),
|
|
458
512
|
)
|
|
459
513
|
end
|
|
460
514
|
|
|
@@ -462,7 +516,7 @@ module Plume
|
|
|
462
516
|
def print_match_clause(node) = "MATCH #{node.type}"
|
|
463
517
|
|
|
464
518
|
def print_indexed_column(node)
|
|
465
|
-
join(identifier(node.name), node.collation && "COLLATE #{identifier(node.collation)}", node.direction&.to_s)
|
|
519
|
+
join(identifier(node.name), node.collation && "COLLATE #{identifier(node.collation)}", node.direction&.to_s, node.nulls && "NULLS #{node.nulls.to_s.upcase}")
|
|
466
520
|
end
|
|
467
521
|
|
|
468
522
|
def print_strict_table_option(_node) = "STRICT"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plume
|
|
4
|
+
# SQL shortened to fit a length, for logs and error trackers, by putting `...` where the parts
|
|
5
|
+
# least needed to recognise the query were: result columns, WHERE conditions, VALUES rows, CTE
|
|
6
|
+
# bodies, INSERT column lists and UPDATE assignments - the most deeply nested first, the longest
|
|
7
|
+
# first among those. Everything else stays as written. When that is not enough, the end is cut off
|
|
8
|
+
module Truncate
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
ELLIPSIS = "..."
|
|
12
|
+
|
|
13
|
+
def call(sql, max_length)
|
|
14
|
+
return sql if sql.length <= max_length
|
|
15
|
+
|
|
16
|
+
src = sql.b
|
|
17
|
+
chosen = []
|
|
18
|
+
result = sql
|
|
19
|
+
candidates(Parser.new(sql).parse).each do |beg, fin, _depth|
|
|
20
|
+
next if fin - beg <= ELLIPSIS.bytesize || chosen.any? { |b, f| b <= beg && fin <= f }
|
|
21
|
+
|
|
22
|
+
chosen.reject! { |b, f| beg <= b && f <= fin }
|
|
23
|
+
chosen << [beg, fin]
|
|
24
|
+
result = render(src, chosen, sql.encoding)
|
|
25
|
+
return result if result.length <= max_length
|
|
26
|
+
end
|
|
27
|
+
"#{result[0, [max_length - ELLIPSIS.length, 0].max]}#{ELLIPSIS}"[0, max_length]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Byte ranges that may be cut, with how deeply they are nested, in the order to cut them
|
|
31
|
+
def candidates(statements)
|
|
32
|
+
found = []
|
|
33
|
+
stack = statements.reverse.map { |s| [s, 0] }
|
|
34
|
+
while (node, depth = stack.pop)
|
|
35
|
+
span =
|
|
36
|
+
case node
|
|
37
|
+
when SelectCore then [list_span(node.columns), node.condition&.location]
|
|
38
|
+
when ValuesClause then [list_span(node.rows)]
|
|
39
|
+
when CommonTableExpression then [node.select&.location]
|
|
40
|
+
when InsertStatement then [list_span(node.columns)]
|
|
41
|
+
when UpdateStatement then [list_span(node.assignments)]
|
|
42
|
+
else []
|
|
43
|
+
end
|
|
44
|
+
span.compact.each { |beg, fin| found << [beg, fin, depth] }
|
|
45
|
+
node.children.reverse_each { |child| stack << [child, depth + 1] }
|
|
46
|
+
end
|
|
47
|
+
found.sort_by { |beg, fin, depth| [-depth, beg - fin] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def list_span(nodes)
|
|
53
|
+
first, last = nodes&.first&.location, nodes&.last&.location
|
|
54
|
+
[first[0], last[1]] if first && last
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render(src, spans, encoding)
|
|
58
|
+
out = +""
|
|
59
|
+
at = 0
|
|
60
|
+
spans.sort.each do |beg, fin|
|
|
61
|
+
out << src.byteslice(at, beg - at) << ELLIPSIS
|
|
62
|
+
at = fin
|
|
63
|
+
end
|
|
64
|
+
(out << src.byteslice(at, src.bytesize - at)).force_encoding(encoding)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/plume/version.rb
CHANGED
data/lib/plume.rb
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
require "plume/version"
|
|
4
4
|
require "literal"
|
|
5
5
|
require "plume/union"
|
|
6
|
+
require "plume/literal_patches"
|
|
6
7
|
|
|
7
8
|
module Plume
|
|
8
9
|
extend Literal::Types
|
|
9
10
|
extend Union::Types
|
|
11
|
+
extend LiteralPatches::Types if LiteralPatches::DEFERRED_WRITES_ON_CHECK
|
|
12
|
+
|
|
13
|
+
# JRuby and TruffleRuby have no Ractors, so there is nothing to make shareable for
|
|
14
|
+
def self.make_shareable(obj) = defined?(Ractor) ? Ractor.make_shareable(obj) : obj
|
|
10
15
|
|
|
11
16
|
autoload :Lexer, "plume/lexer"
|
|
12
17
|
autoload :Parser, "plume/parser"
|
|
@@ -14,7 +19,15 @@ module Plume
|
|
|
14
19
|
autoload :SQLiteGrammar, "plume/sqlite_grammar"
|
|
15
20
|
autoload :Catalog, "plume/catalog"
|
|
16
21
|
autoload :Printer, "plume/printer"
|
|
22
|
+
autoload :PrettyPrinter, "plume/pretty_printer"
|
|
17
23
|
autoload :Analysis, "plume/analysis"
|
|
24
|
+
autoload :Parameters, "plume/parameters"
|
|
25
|
+
autoload :Dump, "plume/dump"
|
|
26
|
+
autoload :Truncate, "plume/truncate"
|
|
27
|
+
autoload :Comment, "plume/comment"
|
|
28
|
+
autoload :Comments, "plume/comment"
|
|
29
|
+
autoload :Normalize, "plume/normalize"
|
|
30
|
+
autoload :Fingerprint, "plume/fingerprint"
|
|
18
31
|
|
|
19
32
|
autoload :BinaryExpression, "plume/ast/binary_expression"
|
|
20
33
|
autoload :ParentheticalExpression, "plume/ast/parenthetical_expression"
|
|
@@ -33,6 +46,7 @@ module Plume
|
|
|
33
46
|
autoload :CreateTableStatement, "plume/ast/create_table_statement"
|
|
34
47
|
autoload :CreateViewStatement, "plume/ast/create_view_statement"
|
|
35
48
|
autoload :DefaultColumnConstraint, "plume/ast/default_column_constraint"
|
|
49
|
+
autoload :DeferrableColumnConstraint, "plume/ast/deferrable_column_constraint"
|
|
36
50
|
autoload :EmptyFunctionArgument, "plume/ast/empty_function_argument"
|
|
37
51
|
autoload :Expression, "plume/ast/expression"
|
|
38
52
|
autoload :ForeignKeyAction, "plume/ast/foreign_key_action"
|
|
@@ -118,17 +132,113 @@ module Plume
|
|
|
118
132
|
autoload :WindowDefinition, "plume/ast/window_definition"
|
|
119
133
|
autoload :WithClause, "plume/ast/with_clause"
|
|
120
134
|
|
|
121
|
-
|
|
135
|
+
# Where parsing gave up, for editors and linters that want more than the message:
|
|
136
|
+
# `location` is the byte range `[beg, fin]` of the offending token, as `Node#location` gives it,
|
|
137
|
+
# `token` its type (nil at the end of input), `line` counts from 1
|
|
138
|
+
class SyntaxError < StandardError
|
|
139
|
+
attr_reader :location, :token, :line, :expected
|
|
140
|
+
|
|
141
|
+
def initialize(message = nil, location: nil, token: nil, line: nil, expected: [])
|
|
142
|
+
@location, @token, @line, @expected = location, token, line, expected
|
|
143
|
+
super(message)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def position = location&.first
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# What running out of stack raises. JRuby lets a Java StackOverflowError through
|
|
150
|
+
# when the stack runs out inside its own Java code rather than in Ruby
|
|
151
|
+
STACK_EXHAUSTED =
|
|
152
|
+
if defined?(JRUBY_VERSION)
|
|
153
|
+
require "java"
|
|
154
|
+
[SystemStackError, Java::JavaLang::StackOverflowError].freeze
|
|
155
|
+
else
|
|
156
|
+
[SystemStackError].freeze
|
|
157
|
+
end
|
|
122
158
|
|
|
123
|
-
|
|
159
|
+
# `with_positions` adds each token's byte range `beg, fin`, after the value if there is one
|
|
160
|
+
def self.tokenize(str, with_values: false, with_positions: false, skip_spaces: true)
|
|
124
161
|
lex = Lexer.new(str, skip_spaces: skip_spaces)
|
|
125
162
|
[].tap do |tokens|
|
|
126
163
|
while (token = lex.next_token)
|
|
127
|
-
tokens <<
|
|
164
|
+
tokens <<
|
|
165
|
+
if with_positions
|
|
166
|
+
with_values ? [token, lex.value, lex.anchor, lex.cursor] : [token, lex.anchor, lex.cursor]
|
|
167
|
+
else
|
|
168
|
+
with_values ? [token, lex.value] : token
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Loads everything that is otherwise autoloaded and freezes what the node classes know
|
|
175
|
+
# about their props, which is what lets Plume run inside Ractors. Call it from the main
|
|
176
|
+
# Ractor before starting any. Node classes cannot get new props afterwards
|
|
177
|
+
def self.eager_load!
|
|
178
|
+
# SQLiteGrammar is a development tool which pulls in net/http
|
|
179
|
+
(constants - [:SQLiteGrammar]).each { |name| const_get(name) if autoload?(name) }
|
|
180
|
+
Analysis::Resolver
|
|
181
|
+
# Node constructors and type errors reach for these, and Ruby 3.3 cannot autoload outside the main Ractor.
|
|
182
|
+
# Not every Literal version has all of them
|
|
183
|
+
%i[Null TypeError ArgumentError].each { |name| Literal.const_get(name) if Literal.const_defined?(name) }
|
|
184
|
+
# Literal versions that cache a `_Deferred` do so on first use, which a frozen one cannot do.
|
|
185
|
+
# Each node's schema then shares Expression with the rest of its prop types
|
|
186
|
+
Expression === nil
|
|
187
|
+
make_shareable(Token::Tuple)
|
|
188
|
+
constants.each do |name|
|
|
189
|
+
klass = const_get(name)
|
|
190
|
+
klass.make_shareable! if Class === klass && klass <= Node
|
|
191
|
+
end
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The statements in `sql`, found by their tokens alone, so SQL that does not parse splits too.
|
|
196
|
+
# Each is its text from its first token to its last, without the semicolon; `with_positions`
|
|
197
|
+
# gives `[text, beg, fin]` with the byte range. A trigger's body stays in one piece
|
|
198
|
+
def self.split(sql, with_positions: false)
|
|
199
|
+
lexer = Lexer.new(sql, skip_spaces: true)
|
|
200
|
+
statements = []
|
|
201
|
+
beg = fin = nil
|
|
202
|
+
leading = [] # the first tokens of the statement, enough to tell a CREATE TRIGGER
|
|
203
|
+
body = cases = 0
|
|
204
|
+
|
|
205
|
+
while (token = lexer.next_token)
|
|
206
|
+
if token == :SEMI && body.zero?
|
|
207
|
+
statements << [beg, fin] if beg
|
|
208
|
+
beg = nil
|
|
209
|
+
leading.clear
|
|
210
|
+
cases = 0
|
|
211
|
+
next
|
|
128
212
|
end
|
|
213
|
+
|
|
214
|
+
beg ||= lexer.anchor
|
|
215
|
+
fin = lexer.cursor
|
|
216
|
+
leading << token if leading.size < 3
|
|
217
|
+
trigger = leading[0] == :CREATE && (leading[1] == :TRIGGER || ((leading[1] in :TEMP | :TEMPORARY) && leading[2] == :TRIGGER))
|
|
218
|
+
next unless trigger
|
|
219
|
+
|
|
220
|
+
# Only END closes the body, and a CASE inside it has an END of its own
|
|
221
|
+
case token
|
|
222
|
+
when :BEGIN then body += 1 if body.zero?
|
|
223
|
+
when :CASE then cases += 1 if body.positive?
|
|
224
|
+
when :END
|
|
225
|
+
if cases.positive? then cases -= 1
|
|
226
|
+
elsif body.positive? then body -= 1
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
statements << [beg, fin] if beg
|
|
231
|
+
|
|
232
|
+
src = sql.b
|
|
233
|
+
statements.map do |b, f|
|
|
234
|
+
text = src.byteslice(b, f - b).force_encoding(sql.encoding)
|
|
235
|
+
with_positions ? [text, b, f] : text
|
|
129
236
|
end
|
|
130
237
|
end
|
|
131
238
|
|
|
239
|
+
# Every comment in `sql`, in order
|
|
240
|
+
def self.comments(sql) = Comments.in(sql)
|
|
241
|
+
|
|
132
242
|
def self.parse(sql)
|
|
133
243
|
Parser.new(sql).parse
|
|
134
244
|
end
|
|
@@ -143,6 +253,27 @@ module Plume
|
|
|
143
253
|
" #{line}\n #{caret}"
|
|
144
254
|
end
|
|
145
255
|
|
|
256
|
+
# The bind parameters of one statement, given as SQL or already parsed
|
|
257
|
+
def self.parameters(sql)
|
|
258
|
+
statements = (Node === sql) ? [sql] : Array(Parser.new(sql).parse)
|
|
259
|
+
raise ArgumentError, "expected exactly one statement, got #{statements.size}" unless statements.size == 1
|
|
260
|
+
|
|
261
|
+
Parameters.new(statements.first)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A tree from what `Node#dump` gave, or a list of them
|
|
265
|
+
def self.load(dump) = Dump.load(dump)
|
|
266
|
+
# At most `max_length` characters of `sql`, with the least telling parts left out first,
|
|
267
|
+
# see Plume::Truncate. A parsed tree is printed first
|
|
268
|
+
def self.truncate(sql, max_length) = Truncate.call((Node === sql) ? sql.to_sql : sql, max_length)
|
|
269
|
+
# The SQL with its constants turned into `?`, see Plume::Normalize. A parsed tree gives a tree back
|
|
270
|
+
def self.normalize(sql) = (Node === sql) ? Normalize.tree(sql) : Normalize.text(sql)
|
|
271
|
+
|
|
272
|
+
# The same for the same query whatever its values and spelling, see Plume::Fingerprint
|
|
273
|
+
def self.fingerprint(sql) = Fingerprint.of((Node === sql || Array === sql) ? sql : Parser.new(sql).parse)
|
|
274
|
+
|
|
146
275
|
def self.reads(...) = Analysis.reads(...)
|
|
276
|
+
def self.writes(...) = Analysis.writes(...)
|
|
147
277
|
def self.access(...) = Analysis.access(...)
|
|
278
|
+
def self.summary(...) = Analysis.summary(...)
|
|
148
279
|
end
|