plume 0.1.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.
Files changed (124) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +506 -11
  3. data/lib/plume/analysis/resolver.rb +797 -0
  4. data/lib/plume/analysis.rb +211 -0
  5. data/lib/plume/ast/alter_table_statement.rb +40 -0
  6. data/lib/plume/ast/analyze_statement.rb +15 -0
  7. data/lib/plume/ast/assignment.rb +18 -0
  8. data/lib/plume/ast/attach_statement.rb +17 -0
  9. data/lib/plume/ast/begin_statement.rb +17 -0
  10. data/lib/plume/ast/binary_expression.rb +71 -0
  11. data/lib/plume/ast/case_condition.rb +29 -0
  12. data/lib/plume/ast/case_expression.rb +22 -0
  13. data/lib/plume/ast/cast_expression.rb +16 -0
  14. data/lib/plume/ast/check_column_constraint.rb +20 -0
  15. data/lib/plume/ast/check_table_constraint.rb +20 -0
  16. data/lib/plume/ast/collate_column_constraint.rb +19 -0
  17. data/lib/plume/ast/collation_expression.rb +26 -0
  18. data/lib/plume/ast/column_constraint.rb +7 -0
  19. data/lib/plume/ast/column_definition.rb +16 -0
  20. data/lib/plume/ast/column_name.rb +22 -0
  21. data/lib/plume/ast/column_type.rb +26 -0
  22. data/lib/plume/ast/commit_statement.rb +13 -0
  23. data/lib/plume/ast/common_table_expression.rb +31 -0
  24. data/lib/plume/ast/compound_select.rb +30 -0
  25. data/lib/plume/ast/conflict_clause.rb +22 -0
  26. data/lib/plume/ast/conflict_resolution.rb +20 -0
  27. data/lib/plume/ast/create_index_statement.rb +29 -0
  28. data/lib/plume/ast/create_table_statement.rb +47 -0
  29. data/lib/plume/ast/create_trigger_statement.rb +43 -0
  30. data/lib/plume/ast/create_view_statement.rb +30 -0
  31. data/lib/plume/ast/create_virtual_table_statement.rb +28 -0
  32. data/lib/plume/ast/default_column_constraint.rb +29 -0
  33. data/lib/plume/ast/deferrable_column_constraint.rb +20 -0
  34. data/lib/plume/ast/delete_statement.rb +28 -0
  35. data/lib/plume/ast/detach_statement.rb +13 -0
  36. data/lib/plume/ast/drop_statement.rb +21 -0
  37. data/lib/plume/ast/empty_function_argument.rb +6 -0
  38. data/lib/plume/ast/exists_expression.rb +15 -0
  39. data/lib/plume/ast/explain_statement.rb +15 -0
  40. data/lib/plume/ast/expression.rb +30 -0
  41. data/lib/plume/ast/foreign_key_action.rb +26 -0
  42. data/lib/plume/ast/foreign_key_clause.rb +50 -0
  43. data/lib/plume/ast/foreign_key_column_constraint.rb +16 -0
  44. data/lib/plume/ast/foreign_key_table_constraint.rb +20 -0
  45. data/lib/plume/ast/frame_boundary.rb +35 -0
  46. data/lib/plume/ast/frame_spec.rb +34 -0
  47. data/lib/plume/ast/function_arguments.rb +18 -0
  48. data/lib/plume/ast/function_reference.rb +29 -0
  49. data/lib/plume/ast/generated_as_column_constraint.rb +24 -0
  50. data/lib/plume/ast/indexed_column.rb +24 -0
  51. data/lib/plume/ast/insert_statement.rb +35 -0
  52. data/lib/plume/ast/join.rb +12 -0
  53. data/lib/plume/ast/join_clause.rb +15 -0
  54. data/lib/plume/ast/join_on_constraint.rb +12 -0
  55. data/lib/plume/ast/join_operator.rb +31 -0
  56. data/lib/plume/ast/join_using_constraint.rb +16 -0
  57. data/lib/plume/ast/like_expression.rb +19 -0
  58. data/lib/plume/ast/literal_expression.rb +29 -0
  59. data/lib/plume/ast/literal_false.rb +7 -0
  60. data/lib/plume/ast/literal_nil.rb +7 -0
  61. data/lib/plume/ast/literal_value.rb +18 -0
  62. data/lib/plume/ast/match_clause.rb +15 -0
  63. data/lib/plume/ast/node.rb +330 -0
  64. data/lib/plume/ast/noop_column_constraint.rb +15 -0
  65. data/lib/plume/ast/noop_table_constraint.rb +15 -0
  66. data/lib/plume/ast/not_like_expression.rb +7 -0
  67. data/lib/plume/ast/not_null_column_constraint.rb +17 -0
  68. data/lib/plume/ast/null_column_constraint.rb +17 -0
  69. data/lib/plume/ast/ordering_term.rb +32 -0
  70. data/lib/plume/ast/over_clause.rb +25 -0
  71. data/lib/plume/ast/parenthetical_expression.rb +37 -0
  72. data/lib/plume/ast/parenthetical_source.rb +19 -0
  73. data/lib/plume/ast/pragma_statement.rb +26 -0
  74. data/lib/plume/ast/primary_key_column_constraint.rb +23 -0
  75. data/lib/plume/ast/primary_key_table_constraint.rb +23 -0
  76. data/lib/plume/ast/raise_expression.rb +23 -0
  77. data/lib/plume/ast/reindex_statement.rb +15 -0
  78. data/lib/plume/ast/release_statement.rb +16 -0
  79. data/lib/plume/ast/result_column.rb +18 -0
  80. data/lib/plume/ast/returning_clause.rb +12 -0
  81. data/lib/plume/ast/rollback_statement.rb +19 -0
  82. data/lib/plume/ast/savepoint_statement.rb +16 -0
  83. data/lib/plume/ast/select_core.rb +27 -0
  84. data/lib/plume/ast/select_statement.rb +34 -0
  85. data/lib/plume/ast/star_function_argument.rb +6 -0
  86. data/lib/plume/ast/star_result_column.rb +16 -0
  87. data/lib/plume/ast/strict_table_option.rb +6 -0
  88. data/lib/plume/ast/subquery_expression.rb +14 -0
  89. data/lib/plume/ast/subquery_source.rb +19 -0
  90. data/lib/plume/ast/table_constraint.rb +7 -0
  91. data/lib/plume/ast/table_function.rb +28 -0
  92. data/lib/plume/ast/table_name.rb +28 -0
  93. data/lib/plume/ast/table_option.rb +7 -0
  94. data/lib/plume/ast/ternary_expression.rb +26 -0
  95. data/lib/plume/ast/token.rb +183 -0
  96. data/lib/plume/ast/unary_expression.rb +36 -0
  97. data/lib/plume/ast/unique_column_constraint.rb +17 -0
  98. data/lib/plume/ast/unique_table_constraint.rb +20 -0
  99. data/lib/plume/ast/update_statement.rb +35 -0
  100. data/lib/plume/ast/upsert_clause.rb +26 -0
  101. data/lib/plume/ast/vacuum_statement.rb +18 -0
  102. data/lib/plume/ast/values_clause.rb +12 -0
  103. data/lib/plume/ast/values_row.rb +13 -0
  104. data/lib/plume/ast/variable.rb +15 -0
  105. data/lib/plume/ast/window_definition.rb +26 -0
  106. data/lib/plume/ast/with_clause.rb +15 -0
  107. data/lib/plume/ast/without_rowid_table_option.rb +6 -0
  108. data/lib/plume/catalog.rb +276 -0
  109. data/lib/plume/comment.rb +73 -0
  110. data/lib/plume/dump.rb +143 -0
  111. data/lib/plume/fingerprint.rb +86 -0
  112. data/lib/plume/lexer.rb +640 -0
  113. data/lib/plume/literal_patches.rb +76 -0
  114. data/lib/plume/normalize.rb +65 -0
  115. data/lib/plume/parameters.rb +52 -0
  116. data/lib/plume/parser.rb +4064 -0
  117. data/lib/plume/pretty_printer.rb +202 -0
  118. data/lib/plume/printer.rb +525 -0
  119. data/lib/plume/sqlite_grammar.rb +86 -0
  120. data/lib/plume/truncate.rb +67 -0
  121. data/lib/plume/union.rb +28 -0
  122. data/lib/plume/version.rb +1 -1
  123. data/lib/plume.rb +274 -3
  124. metadata +176 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9e39a7313fe99e4e087c4d486cf07bdda3fc549f6f218b88379bf548ea4f3d0
4
- data.tar.gz: c1e7f06a6894e747befa1a6c4243bd56a6452a93415ad955ddead14ec01e77d1
3
+ metadata.gz: 94377beb43ecb80343f932d40da7aa323d8229a6fb6dba2d83c6dcaf31385a45
4
+ data.tar.gz: 38f780903f0665b23423653f38dfca576daf28535521917a426daf1dc01b9871
5
5
  SHA512:
6
- metadata.gz: 482bc86238132bf117c58a6aec4bc40839f9c203dc525ccfe57f10aa3ded097efd4aa5b593a43adbd8ca33def03a013b37f9487833d24733ff9e0443557a58dd
7
- data.tar.gz: 6373ed11b0debe32f204204aeb69bc2fa6708f6861e7a0ef02f7a9ab75e0870204bfcd825fb4c0e1436740435a2669fcdd06731997be7c8d76e7cd1d5a5f5200
6
+ metadata.gz: e2402f5627d6cec60c889729e465bcc29ce932beaf836d86ce86bc93e2bc617dbb7b067335a229333f8b2cba28faf2a13764e4443e9e1a22d806deaba28054e7
7
+ data.tar.gz: b1d4dcf873e71476020dcf6c00ee647d5cded80c443f4df6d5f5da04a1b1a5c6436500c1ab2e80e7704c49409304aec29e9a8737e080a8e8c1166d53f8a692d3
data/README.md CHANGED
@@ -1,38 +1,533 @@
1
1
  # Plume
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ [![Gem Version](https://badge.fury.io/rb/plume.svg)](https://rubygems.org/gems/plume)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/plume)](https://rubygems.org/gems/plume)
5
+ ![Tests](https://github.com/yippee-fun/plume/actions/workflows/main.yml/badge.svg)
6
+ [![Sponsors](https://img.shields.io/github/sponsors/fractaledmind?color=eb4aaa&logo=GitHub%20Sponsors)](https://github.com/sponsors/fractaledmind)
7
+ [![Twitter Follow](https://img.shields.io/twitter/url?label=%40fractaledmind&style=social&url=https%3A%2F%2Ftwitter.com%2Ffractaledmind)](https://twitter.com/fractaledmind)
4
8
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/plume`. To experiment with that code, run `bin/console` for an interactive prompt.
9
+ <img src="/assets/logo.svg" width="100" alt="Plume logo: a blue feather quill pen" align="right" />
6
10
 
7
- ## Installation
11
+ Plume is a Ruby library for working with SQLite elegantly. Presently, this means that it provides a pure Ruby implementation of SQLite's lexer/tokenizer as well as a 100% compatible parser. This is useful for introspecting the structure of SQL queries, and for building tools that work with SQL queries - like working out which tables and columns a query is going to read.
8
12
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
13
+ ## Installation
10
14
 
11
- Install the gem and add to the application's Gemfile by executing:
15
+ Plume needs Ruby 3.2 or later. Install the gem and add to the application's Gemfile by executing:
12
16
 
13
17
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
18
+ bundle add plume
15
19
  ```
16
20
 
17
21
  If bundler is not being used to manage dependencies, install the gem by executing:
18
22
 
19
23
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
24
+ gem install plume
21
25
  ```
22
26
 
23
27
  ## Usage
24
28
 
25
- TODO: Write usage instructions here
29
+ Every statement in SQLite's grammar parses, and the parser is checked against the SQL in SQLite's own test suite - both the SQL written in its test files and the SQL they build as they run. Of the 26,977 fixture files in that corpus, Plume parses 26,707 and rejects 266 that SQLite rejects too. The 4 left are deliberate: 3 that Plume parses but SQLite refuses for reasons other than syntax, and 1 expression nested 1,000 deep, past what Plume's stack allows.
30
+
31
+ Parse SQL with `Plume.parse`, which returns one node per statement:
32
+
33
+ ```ruby
34
+ sql = <<~SQL
35
+ CREATE TEMP TABLE IF NOT EXISTS `schema0`."tb0" (
36
+ c0 INTEGER PRIMARY KEY DESC ON CONFLICT ABORT AUTOINCREMENT,
37
+ c1 INT NOT NULL ON CONFLICT ROLLBACK,
38
+ c2 TEXT UNIQUE ON CONFLICT IGNORE,
39
+ c3 BLOB CHECK ( c3 > 0),
40
+ c4 REAL DEFAULT (1.1 * 2.2),
41
+ c5 ANY COLLATE RTRIM,
42
+ c6 DECIMAL(4, 6) GENERATED ALWAYS AS (1.1 + 2.2) STORED,
43
+ c7 REFERENCES tb1(c1) ON DELETE SET NULL ON UPDATE CASCADE MATCH FULL DEFERRABLE INITIALLY DEFERRED,
44
+ PRIMARY KEY (c0, c1 AUTOINCREMENT) ON CONFLICT ABORT,
45
+ UNIQUE (c0, c1) ON CONFLICT ROLLBACK
46
+ CHECK (c0 > 0)
47
+ FOREIGN KEY (c0) REFERENCES tb1(c1) ON DELETE CASCADE ON UPDATE RESTRICT MATCH FULL DEFERRABLE INITIALLY DEFERRED
48
+ ) WITHOUT ROWID;
49
+ SQL
50
+ ast = Plume.parse(sql).first # one node per statement
51
+ # => Plume::CreateTableStatement(...)
52
+ ```
53
+
54
+ <details>
55
+ <summary>(click to see full AST)</summary>
56
+
57
+ ```ruby
58
+ Plume::CreateTableStatement(
59
+ temporary = true,
60
+ if_not_exists = true,
61
+ table = Plume::TableName(
62
+ schema = "schema0",
63
+ table = "tb0",
64
+ ),
65
+ columns = [
66
+ Plume::ColumnDefinition(
67
+ name = "c0",
68
+ type = Plume::ColumnType(
69
+ text = "INTEGER",
70
+ affinity = :INTEGER,
71
+ ),
72
+ constraints = [
73
+ Plume::PrimaryKeyColumnConstraint(...),
74
+ ],
75
+ ),
76
+ Plume::ColumnDefinition(
77
+ name = "c1",
78
+ type = Plume::ColumnType(
79
+ text = "INT",
80
+ affinity = :INTEGER,
81
+ ),
82
+ constraints = [
83
+ Plume::NotNullColumnConstraint(...),
84
+ ],
85
+ ),
86
+ Plume::ColumnDefinition(
87
+ name = "c2",
88
+ type = Plume::ColumnType(
89
+ text = "TEXT",
90
+ affinity = :TEXT,
91
+ ),
92
+ constraints = [
93
+ Plume::UniqueColumnConstraint(...),
94
+ ],
95
+ ),
96
+ Plume::ColumnDefinition(
97
+ name = "c3",
98
+ type = Plume::ColumnType(
99
+ text = "BLOB",
100
+ affinity = :BLOB,
101
+ ),
102
+ constraints = [
103
+ Plume::CheckColumnConstraint(...),
104
+ ],
105
+ ),
106
+ Plume::ColumnDefinition(
107
+ name = "c4",
108
+ type = Plume::ColumnType(
109
+ text = "REAL",
110
+ affinity = :REAL,
111
+ ),
112
+ constraints = [
113
+ Plume::DefaultColumnConstraint(...),
114
+ ],
115
+ ),
116
+ Plume::ColumnDefinition(
117
+ name = "c5",
118
+ type = Plume::ColumnType(
119
+ text = "ANY",
120
+ affinity = :ANY,
121
+ ),
122
+ constraints = [
123
+ Plume::CollateColumnConstraint(...),
124
+ ],
125
+ ),
126
+ Plume::ColumnDefinition(
127
+ name = "c6",
128
+ type = Plume::ColumnType(
129
+ text = "DECIMAL(4, 6)",
130
+ affinity = :ANY,
131
+ ),
132
+ constraints = [
133
+ Plume::GeneratedAsColumnConstraint(...),
134
+ ],
135
+ ),
136
+ Plume::ColumnDefinition(
137
+ name = "c7",
138
+ constraints = [
139
+ Plume::ForeignKeyColumnConstraint(...),
140
+ ],
141
+ ),
142
+ ],
143
+ constraints = [
144
+ Plume::PrimaryKeyTableConstraint(
145
+ autoincrement = true,
146
+ columns = [
147
+ Plume::IndexedColumn(...),
148
+ Plume::IndexedColumn(...),
149
+ ],
150
+ conflict_clause = Plume::ConflictClause(resolution = :ABORT),
151
+ ),
152
+ Plume::UniqueTableConstraint(
153
+ columns = [
154
+ Plume::IndexedColumn(...),
155
+ Plume::IndexedColumn(...),
156
+ ],
157
+ conflict_clause = Plume::ConflictClause(resolution = :ROLLBACK),
158
+ ),
159
+ Plume::CheckTableConstraint(
160
+ expression = Plume::BinaryExpression(
161
+ operator = :ABOVE,
162
+ left = Plume::ColumnName(...),
163
+ right = Plume::LiteralExpression(...),
164
+ ),
165
+ ),
166
+ Plume::ForeignKeyTableConstraint(
167
+ columns = [Plume::IndexedColumn(...)],
168
+ foreign_key_clause = Plume::ForeignKeyClause(
169
+ table = "tb1",
170
+ deferred = true,
171
+ columns = [...],
172
+ actions = [...],
173
+ match_clauses = [...],
174
+ ),
175
+ ),
176
+ ],
177
+ options = [
178
+ Plume::WithoutRowidTableOption(),
179
+ ],
180
+ strict = false,
181
+ without_row_id = true,
182
+ )
183
+ ```
184
+ </details>
185
+
186
+
187
+ Turn a tree back into SQL with `to_sql`. It prints from the nodes' values rather than copying the source, so trees built by hand or changed after parsing print too:
188
+
189
+ ```ruby
190
+ stmt = Plume.parse("select a from t where b = 1").first
191
+ stmt.body.condition = Plume::BinaryExpression.new(
192
+ operator: :ALL,
193
+ left: stmt.body.condition,
194
+ right: Plume::BinaryExpression.new(operator: :EQUALS, left: Plume::ColumnName.new(column: "tenant_id"), right: Plume::Variable.new(name: "?")),
195
+ )
196
+ stmt.to_sql
197
+ # => "SELECT a FROM t WHERE (b = 1) AND (tenant_id = ?)"
198
+ ```
199
+
200
+ `walk` yields every node with its parent, and `walk_with_slots` also gives the prop the node sits in, plus its index when that prop is a list. `transform` builds a changed copy and leaves the original alone. Return a node from the block to keep it, or another node to put in its place:
201
+
202
+ ```ruby
203
+ stmt = Plume.parse("SELECT a FROM t WHERE b IN (1, 2) ORDER BY 1").first
204
+ stmt.transform { |node, parent| Plume::LiteralExpression === node && !(Plume::OrderingTerm === parent) ? Plume::Variable.new(name: "?") : node }.to_sql
205
+ # => "SELECT a FROM t WHERE b IN (?, ?) ORDER BY 1"
206
+ ```
207
+
208
+ Only the nodes on the way to a replacement are copied, and the rest is shared with the original. Neither method recurses, so trees as deep as SQLite allows are fine. This works on shareable trees as well.
209
+
210
+ `Plume.split` finds the statements in a script by their tokens alone, so it also works on SQL that doesn't parse. A trigger's body stays in one piece:
211
+
212
+ ```ruby
213
+ Plume.split("SELECT 1; CREATE TRIGGER t AFTER INSERT ON x BEGIN DELETE FROM y; END; SELEC oops")
214
+ # => ["SELECT 1", "CREATE TRIGGER t AFTER INSERT ON x BEGIN DELETE FROM y; END", "SELEC oops"]
215
+ ```
216
+ `to_sql(pretty: true)` lays the SQL out over lines. Each clause starts a line. A clause too long for `max_line_length` (80 by default) puts its items one per line, indented by `indent_size` (4), and a long `AND` or `OR` chain puts each operand on its own line. Subqueries are indented inside their parentheses. `commas_start_of_line: true` puts the commas at the start of the lines:
217
+
218
+ ```ruby
219
+ Plume.parse("select id, title from posts p join users u on u.id = p.user_id where u.active and p.draft = 0").first.to_sql(pretty: true)
220
+ # => SELECT id, title
221
+ # FROM posts AS p
222
+ # JOIN users AS u ON u.id = p.user_id
223
+ # WHERE u.active AND (p.draft = 0)
224
+ ```
225
+
226
+ Pretty SQL parses back into the same tree, which is checked across the whole fixture corpus.
227
+
228
+ Tokenize SQL with `Plume.tokenize`:
229
+
230
+ ```ruby
231
+ tokens = Plume.tokenize("SELECT * FROM users WHERE id = 1")
232
+ # => [:SELECT, :STAR, :FROM, :ID, :WHERE, :ID, :EQ, :INTEGER]
233
+ ```
234
+
235
+ `with_values: true` adds each token's text, and `with_positions: true` its byte range in the SQL - for highlighting, or for finding a token's place in the original:
236
+
237
+ ```ruby
238
+ Plume.tokenize("SELECT a FROM t", with_values: true, with_positions: true)
239
+ # => [[:SELECT, "SELECT", 0, 6], [:ID, "a", 7, 8], [:FROM, "FROM", 9, 13], [:ID, "t", 14, 15]]
240
+ ```
241
+
242
+ `dump` turns a tree into a plain hash that JSON can carry, and `Plume.load` turns that hash back into the same tree. Positions, keywords as written and the source all come through, so the loaded tree prints, locates and compares as the original did. That makes it possible to cache parsed trees, or to hand them to another process:
243
+
244
+ ```ruby
245
+ json = JSON.generate(Plume.parse("SELECT DISTINCT a FROM t").first.dump)
246
+ Plume.load(JSON.parse(json)).to_sql
247
+ # => "SELECT DISTINCT a FROM t"
248
+ ```
249
+
250
+ Neither `dump` nor `load` recurses, but JSON's own generator and parser do, so a very deep tree needs `max_nesting: false` and enough stack.
251
+
252
+ The lexer knows _183_ token types: _153_ keywords, and _30_ more for punctuation, operators, literals and names. Words like `rowid`, `strict` and `stored`, which SQLite reads as plain names and checks by their text, are keywords here that fall back to names wherever SQLite would take a name.
253
+ The lexer knows _184_ token types: _153_ keywords, and _31_ more for punctuation, operators, literals, names and comments. Words like `rowid`, `strict` and `stored`, which SQLite reads as plain names and checks by their text, are keywords here that fall back to names wherever SQLite would take a name.
254
+
255
+ ### Comments
256
+
257
+ Comments are tokens of their own (`:COMMENT`), which the parser skips the same way SQLite does. `Plume.comments` lists them with their byte ranges. `tags` reads [sqlcommenter](https://google.github.io/sqlcommenter/) comments, including the ones Rails' `query_log_tags` adds, in its current format and the older `key:value` one:
258
+
259
+ ```ruby
260
+ sql = "SELECT * FROM posts /*controller='posts',action='index'*/"
261
+ Plume.comments(sql).first.tags # => {"controller" => "posts", "action" => "index"}
262
+ ```
263
+
264
+ `to_sql(comments: true)` keeps the comments directly before and after a statement, such as a header line or the sqlcommenter tags. Comments inside the statement are left out. A trailing `--` comment is printed as `/* */`, so a semicolon after it doesn't become part of the comment:
265
+
266
+ ```ruby
267
+ Plume.parse("-- report
268
+ select a from t /*app='shop'*/").first.to_sql(comments: true)
269
+ # => "-- report
270
+ SELECT a FROM t /*app='shop'*/"
271
+ ```
272
+
273
+ `Plume.parameters` numbers a statement's bind parameters the way SQLite does - `?` takes the next number, `?N` takes N, and a name keeps the number it first got - so you know what to bind where before preparing anything:
274
+
275
+ ```ruby
276
+ parameters = Plume.parameters("SELECT * FROM posts WHERE user_id = :user AND (title = ? OR body = ?) LIMIT :user")
277
+ parameters.count # => 3
278
+ parameters.names # => [":user", nil, nil]
279
+ parameters.index(":user") # => 1
280
+ parameters.map(&:number) # => [1, 2, 3, 1]
281
+ ```
282
+
283
+ `count`, `names` and `index` answer what `sqlite3_bind_parameter_count`, `_name` and `_index` would. Each parameter also carries its `node`, and with it the `location` it was written at.
284
+
285
+ ### Truncating
286
+
287
+ `Plume.truncate(sql, max_length)` shortens SQL to fit a log line or an error tracker. It replaces the parts you need least to recognise the query with `...`: result columns, `WHERE` conditions, `VALUES` rows, CTE bodies, `INSERT` column lists and `UPDATE` assignments. The most deeply nested parts go first, and the longest first among those. Only if that is still not short enough is the end cut off:
288
+
289
+ ```ruby
290
+ sql = "WITH recent AS (SELECT id, title FROM posts WHERE created_at > ?) SELECT r.id, r.title, u.name FROM recent r JOIN users u ON u.id = r.user_id WHERE u.active"
291
+ Plume.truncate(sql, 100)
292
+ # => "WITH recent AS (...) SELECT ... FROM recent r JOIN users u ON u.id = r.user_id WHERE u.active"
293
+ ```
294
+
295
+ ### Normalizing
296
+
297
+ `Plume.normalize` replaces a query's constants with `?` and leaves everything else as written, so the same query with different values comes out the same. That lets you group a log the way `pg_stat_statements` does, or keep values out of it:
298
+
299
+ ```ruby
300
+ Plume.normalize("SELECT * FROM posts /* app */ WHERE user_id = 42 AND title LIKE 'Re: %' ORDER BY 1 LIMIT 10")
301
+ # => "SELECT * FROM posts /* app */ WHERE user_id = ? AND title LIKE ? ORDER BY 1 LIMIT ?"
302
+ ```
303
+
304
+ Numbers (including their sign), strings and blobs are replaced. `NULL`, `TRUE`, `FALSE` and `CURRENT_*` are left alone, and so is an integer on its own in `ORDER BY` or `GROUP BY`, where SQLite reads it as a column position. Only `SELECT`, `INSERT`, `UPDATE` and `DELETE` are normalized. Pass a parsed statement to get a tree back with `Variable`s in place of the constants. The tests bind the constants back into the normalized SQL and check that SQLite returns the same rows.
305
+
306
+ `Plume.fingerprint` goes one step further and gives the same short hash for every spelling of a query: whatever its values, parameter names, spacing, keyword and name case, or parentheses, and however many constants an `IN` list or `VALUES` holds. It is stable across processes and Ruby versions, so it can be stored:
307
+
308
+ ```ruby
309
+ Plume.fingerprint("SELECT a FROM t WHERE b IN (1, 2, 3)") # => "07b540b0c5ed57e1"
310
+ Plume.fingerprint("select A from T where b in (?)") # => "07b540b0c5ed57e1"
311
+ ```
312
+
313
+ ### What does this query read?
314
+
315
+ `Plume.reads` works out which tables and columns a statement would read, looking through aliases, joins, subqueries, CTEs and views:
316
+
317
+ ```ruby
318
+ catalog = Plume::Catalog.from_sql(<<~SQL)
319
+ CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
320
+ CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INT, title TEXT);
321
+ SQL
322
+
323
+ reads = Plume.reads(<<~SQL, catalog: catalog)
324
+ WITH recent AS (SELECT * FROM posts ORDER BY id DESC LIMIT 10)
325
+ SELECT r.title, u.name FROM recent r JOIN users u ON u.id = r.user_id
326
+ WHERE u.email LIKE '%@example.com'
327
+ SQL
328
+
329
+ reads.tables # => #<Set: {"posts", "users"}>
330
+ reads.columns # => {"posts" => #<Set: {"id", "user_id", "title"}>, "users" => #<Set: {"id", "name", "email"}>}
331
+ reads.projected # columns whose values can end up in the result rows
332
+ reads.referenced # columns only used to filter, join, sort or group
333
+ reads.calls # => #<Set: {"like"}>
334
+ ```
335
+
336
+ `reads.calls` lists the functions the statement calls, including the ones inside the views it reads. It also covers what `LIKE`, `GLOB`, `REGEXP`, `MATCH`, `->`, `->>` and `CURRENT_*` call, because SQLite's authorizer counts those as calls too. Table-valued functions in `FROM`, like `json_each`, are listed separately in `reads.functions`.
337
+
338
+ `reads.ctes` lists the names the statement's `WITH` clauses define, and `reads.aliases` maps each table alias to what it stands for, spelled as written: `{"r" => "recent", "u" => "users"}` above.
339
+
340
+ `Plume.reads` also takes what you already parsed - a statement node, or the array `Plume.parse` returns - so the SQL is not parsed twice, and a tree you changed after parsing is analyzed as changed. So do `Plume.writes` and `Plume.access` below.
341
+
342
+ The catalog can also come from a live database with `Plume::Catalog.from_sqlite(db)`, or be left out - then `SELECT *` is reported as a read of the column `"*"` and unqualified columns only resolve when there is a single table in scope.
343
+
344
+ Anything that cannot be attributed (an unknown table or column, an ambiguous name) raises `Plume::Analysis::Error` with a pointer into the SQL. Pass `strict: false` to get the partial result and a list in `reads.unresolved` instead. Each entry of `reads.references` carries the AST node it came from, so you can point at it too - see [examples/read_guard.rb](examples/read_guard.rb) for a small column-level access check built on top.
345
+
346
+ The results are tested against SQLite's own [authorizer callback](https://www.sqlite.org/c3ref/set_authorizer.html), which is also the place to enforce access rules at runtime when you have a connection. `Plume.reads` is for when you want to vet SQL ahead of time, without a database, or with good error messages. It reports one thing the authorizer does not: the right-hand join columns of `USING` and `NATURAL` joins.
347
+
348
+ ### What does this query write?
349
+
350
+ `Plume.writes` works out which tables and columns an `INSERT`, `UPDATE` or `DELETE` would change. It takes the same `catalog:` and `strict:` as `Plume.reads`:
351
+
352
+ ```ruby
353
+ writes = Plume.writes(<<~SQL, catalog: catalog)
354
+ INSERT INTO posts (user_id, title) VALUES (?, ?)
355
+ ON CONFLICT (id) DO UPDATE SET title = excluded.title
356
+ SQL
357
+
358
+ writes.tables # => #<Set: {"posts"}>
359
+ writes.columns # => {"posts" => #<Set: {"user_id", "title"}>}
360
+ writes.inserted # => {"posts" => #<Set: {"user_id", "title"}>}
361
+ writes.updated # => {"posts" => #<Set: {"title"}>}, from the DO UPDATE
362
+ ```
363
+
364
+ A `DELETE` removes whole rows, so it names a table and no columns:
365
+
366
+ ```ruby
367
+ writes = Plume.writes("DELETE FROM posts WHERE user_id = ?", catalog: catalog)
368
+
369
+ writes.deleted # => #<Set: {"posts"}>
370
+ writes.tables # => #<Set: {"posts"}>
371
+ writes.columns # => {}
372
+ ```
373
+
374
+ Around the edges:
375
+
376
+ - An `INSERT` without a column list writes every column of the table except generated ones, or `"*"` without a catalog. `DEFAULT VALUES` writes the table but names no columns.
377
+ - Setting `rowid`, `oid` or `_rowid_` writes `"ROWID"`, even when an `INTEGER PRIMARY KEY` stands in for it - unless the table has a real column by that name.
378
+ - `RETURNING` and CTEs only read. A CTE never stands in for the table being written, even when it has the same name.
379
+ - Writing to a view reports the view. The triggers a statement fires are not looked into, so what an `INSTEAD OF` trigger or any other trigger writes is not reported, and neither are the rows a `REPLACE` deletes to make room. Pass the `CREATE TRIGGER` itself to find out what a trigger does - see below.
380
+ - A `SELECT` writes nothing.
381
+ - Tables outside `main` are keyed with their schema, as in `"aux.users"`.
382
+
383
+ With `strict: false`, a column the catalog does not have is left out and listed in `writes.unresolved`:
384
+
385
+ ```ruby
386
+ writes = Plume.writes("UPDATE posts SET title = ?, body = ? WHERE id = ?", catalog: catalog, strict: false)
387
+
388
+ writes.updated # => {"posts" => #<Set: {"title"}>}
389
+ writes.unresolved.map(&:name) # => ["body"]
390
+ ```
391
+
392
+ `Plume.access` gives you both the reads and the writes, from one pass over the statement:
393
+
394
+ ```ruby
395
+ access = Plume.access(<<~SQL, catalog: catalog)
396
+ UPDATE posts SET title = upper(u.name) FROM users u WHERE u.id = posts.user_id RETURNING posts.id
397
+ SQL
398
+
399
+ access.writes.updated # => {"posts" => #<Set: {"title"}>}
400
+ access.reads.projected # => {"users" => #<Set: {"name"}>, "posts" => #<Set: {"id"}>}
401
+ access.reads.referenced # => {"users" => #<Set: {"id"}>, "posts" => #<Set: {"user_id"}>}
402
+ ```
403
+
404
+ A value copied into a column being written counts as projected, just like one returned in the result rows.
405
+
406
+ Writes are checked against the authorizer as well, and must match it exactly - except for the columns of an `INSERT`, which the authorizer does not name.
407
+
408
+ `Plume.access`, `reads` and `writes` also take a `CREATE TRIGGER`. They report what its `WHEN` clause and body read, write and call each time the trigger fires. `NEW` and `OLD` stand for the row of the table the trigger is on, so reading `new.total` reads that table, as SQLite's authorizer reports it:
409
+
410
+ ```ruby
411
+ access = Plume.access(<<~SQL, catalog: catalog)
412
+ CREATE TRIGGER audit AFTER UPDATE ON posts WHEN new.title <> old.title BEGIN
413
+ UPDATE users SET name = upper(name) WHERE id = new.user_id;
414
+ END
415
+ SQL
416
+ access.reads.columns # => {"posts" => #<Set: {"title", "user_id"}>, "users" => #<Set: {"name", "id"}>}
417
+ access.writes.updated # => {"users" => #<Set: {"name"}>}
418
+ ```
419
+
420
+ ### What else does a statement do?
421
+
422
+ `Plume.access` takes every other kind of statement too, and reports in `actions` what it does besides reading and writing rows. The names follow SQLite's authorizer action codes, and the actions are checked against it:
423
+
424
+ ```ruby
425
+ catalog = Plume::Catalog.from_sql(<<~SQL)
426
+ CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT UNIQUE);
427
+ CREATE TRIGGER touch AFTER UPDATE ON posts BEGIN SELECT 1; END;
428
+ SQL
429
+
430
+ Plume.access("DROP TABLE posts", catalog:).actions.map { |a| [a.action, a.name, a.detail] }
431
+ # => [[:drop_table, "posts", nil], [:drop_trigger, "touch", "posts"]]
432
+ Plume.access("CREATE TEMP TABLE drafts (slug PRIMARY KEY)").actions.map { |a| [a.action, a.name] }
433
+ # => [[:create_temp_table, "drafts"], [:create_temp_index, "sqlite_autoindex_drafts_1"]]
434
+ ```
435
+
436
+ Each action has an `action` (`:create_table`, `:drop_temp_index`, `:alter_table`, `:pragma`, `:transaction`, `:savepoint`, `:attach`, `:reindex` and the rest), a `name`, a `detail` (the table an index or trigger is on, a pragma's value, the column `ALTER TABLE` drops) and a `schema`.
437
+
438
+ As with SQLite, a `CREATE TABLE … AS` reads what its `SELECT` reads, a `CREATE INDEX` reads the columns it indexes, and `DROP TABLE` deletes the table's rows. A `CREATE VIEW` reads nothing, because SQLite only checks a view's body when the view is used. `EXPLAIN` reports what the statement it explains would do. The catalog also keeps track of indexes and triggers, which is how a `DROP INDEX` knows its table.
439
+
440
+ ### What does a whole script touch?
441
+
442
+ `reads`, `writes` and `access` each take exactly one statement. `Plume.summary` takes any number of statements, such as a migration or a batch from a log, and merges what they touch:
443
+
444
+ ```ruby
445
+ summary = Plume.summary(<<~SQL)
446
+ CREATE TABLE archive (id INTEGER PRIMARY KEY, title TEXT);
447
+ INSERT INTO archive SELECT id, upper(title) FROM posts WHERE id < 100;
448
+ DELETE FROM posts WHERE id < 100;
449
+ SQL
450
+
451
+ summary.statement_types # => ["CreateTableStatement", "InsertStatement", "DeleteStatement"]
452
+ summary.tables # => #<Set: {"posts", "archive"}>
453
+ summary.tables(:delete) # => #<Set: {"posts"}>
454
+ summary.calls # => #<Set: {"upper"}>
455
+ summary.actions.map(&:action) # => [:create_table]
456
+ summary.accesses # => one Plume::Analysis::Access per statement
457
+ ```
458
+
459
+ It takes the same `catalog:` and `strict:` arguments. Each statement sees the schema as the statements before it left it, so a script that creates a table and then fills it checks out against a catalog that has never heard of the table. The changes go to a copy of the catalog, and the one you pass in stays as it was. `Catalog#apply(statement)` makes the same changes to a catalog of your own.
460
+
461
+ ### Ractors
462
+
463
+ Plume loads its parts lazily, and Ractors other than the main one cannot read the class-level state that leaves behind. Call `Plume.eager_load!` once from the main Ractor before starting any, and freeze the catalog with `Ractor.make_shareable` so it can be passed in:
464
+
465
+ ```ruby
466
+ Plume.eager_load!
467
+ catalog = Ractor.make_shareable(Plume::Catalog.from_sql(schema_sql))
468
+
469
+ ractors = queries.each_slice(100).map do |batch|
470
+ Ractor.new(batch, catalog) do |sqls, cat|
471
+ sqls.map { |sql| Plume.reads(sql, catalog: cat, strict: false).columns }
472
+ end
473
+ end
474
+ ractors.flat_map(&:value) # `take` before Ruby 4.0
475
+ ```
476
+
477
+ `tokenize`, `parse`, `to_sql`, `walk`, `reads`, `writes` and `access` all work inside a Ractor. Trees and results come back out like any other object, and a tree passed through `Ractor.make_shareable` can still be read, printed, walked and analyzed - it just can no longer be edited. After `eager_load!` the node classes cannot be given new props.
478
+
479
+ ## Roadmap
480
+
481
+ - [x] `expression`
482
+ - [x] `alter_table_stmt`
483
+ - [x] `analyze_stmt`
484
+ - [x] `attach_stmt`
485
+ - [x] `begin_stmt`
486
+ - [x] `commit_stmt`
487
+ - [x] `create_table_stmt`
488
+ - [x] `create_index_stmt`
489
+ - [x] `create_trigger_stmt`
490
+ - [x] `create_view_stmt`
491
+ - [x] `create_virtual_table_stmt`
492
+ - [x] `delete_stmt`
493
+ - [x] `detach_stmt`
494
+ - [x] `drop_index_stmt`
495
+ - [x] `drop_table_stmt`
496
+ - [x] `drop_trigger_stmt`
497
+ - [x] `drop_view_stmt`
498
+ - [x] `insert_stmt`
499
+ - [x] `pragma_stmt`
500
+ - [x] `reindex_stmt`
501
+ - [x] `release_stmt`
502
+ - [x] `rollback_stmt`
503
+ - [x] `savepoint_stmt`
504
+ - [x] `select_stmt`
505
+ - [x] `update_stmt`
506
+ - [x] `vacuum_stmt`
507
+ - [x] `with_stmt`
508
+
509
+ ## Performance
510
+
511
+ `bin/benchmark` runs every fixture that parses (26,707 files, 5.5 MB of SQL). On an Apple M5 Pro with Ruby 4.0:
512
+
513
+ | Step | Fixtures/s | MB/s | MB/s with YJIT |
514
+ | --- | ---: | ---: | ---: |
515
+ | `Plume.tokenize` | ~30,000 | 6.4 | 18.5 |
516
+ | `Plume.parse` | ~2,900 | 0.62 | 0.66 |
517
+ | `Node#to_sql` | ~12,000 | 2.5 | 2.3 |
518
+ | `Plume.reads` (no catalog, `SELECT`s only) | ~2,200 | 0.38 | 0.46 |
519
+
520
+ The corpus leans on very large generated expressions; a typical application query parses in well under a millisecond. About half of the parse time goes to Literal's runtime type checks on every node property.
26
521
 
27
522
  ## Development
28
523
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
524
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. `bin/fixture-report` shows how much of SQLite's own test SQL parses (pass `--write` to record it as the new baseline the tests hold us to), and `bin/benchmark` times the whole pipeline. `bin/trace-fixtures` runs SQLite's Tcl test suite and adds a fixture for each shape of statement it finds that the corpus lacks, and for each one where Plume and SQLite disagree. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
525
 
31
526
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
527
 
33
528
  ## Contributing
34
529
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/plume. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/plume/blob/main/CODE_OF_CONDUCT.md).
530
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yippee-fun/plume. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/yippee-fun/plume/blob/main/CODE_OF_CONDUCT.md).
36
531
 
37
532
  ## License
38
533
 
@@ -40,4 +535,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
40
535
 
41
536
  ## Code of Conduct
42
537
 
43
- Everyone interacting in the Plume project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/plume/blob/main/CODE_OF_CONDUCT.md).
538
+ Everyone interacting in the Plume project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/yippee-fun/plume/blob/main/CODE_OF_CONDUCT.md).