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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94377beb43ecb80343f932d40da7aa323d8229a6fb6dba2d83c6dcaf31385a45
|
|
4
|
+
data.tar.gz: 38f780903f0665b23423653f38dfca576daf28535521917a426daf1dc01b9871
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2402f5627d6cec60c889729e465bcc29ce932beaf836d86ce86bc93e2bc617dbb7b067335a229333f8b2cba28faf2a13764e4443e9e1a22d806deaba28054e7
|
|
7
|
+
data.tar.gz: b1d4dcf873e71476020dcf6c00ee647d5cded80c443f4df6d5f5da04a1b1a5c6436500c1ab2e80e7704c49409304aec29e9a8737e080a8e8c1166d53f8a692d3
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Plume is a Ruby library for working with SQLite elegantly. Presently, this means
|
|
|
12
12
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
15
|
-
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:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
bundle add plume
|
|
@@ -26,9 +26,9 @@ gem install plume
|
|
|
26
26
|
|
|
27
27
|
## Usage
|
|
28
28
|
|
|
29
|
-
|
|
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
30
|
|
|
31
|
-
|
|
31
|
+
Parse SQL with `Plume.parse`, which returns one node per statement:
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
34
34
|
sql = <<~SQL
|
|
@@ -177,7 +177,6 @@ Plume::CreateTableStatement(
|
|
|
177
177
|
options = [
|
|
178
178
|
Plume::WithoutRowidTableOption(),
|
|
179
179
|
],
|
|
180
|
-
select_statement = Object(),
|
|
181
180
|
strict = false,
|
|
182
181
|
without_row_id = true,
|
|
183
182
|
)
|
|
@@ -198,14 +197,118 @@ stmt.to_sql
|
|
|
198
197
|
# => "SELECT a FROM t WHERE (b = 1) AND (tenant_id = ?)"
|
|
199
198
|
```
|
|
200
199
|
|
|
201
|
-
|
|
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`:
|
|
202
229
|
|
|
203
230
|
```ruby
|
|
204
231
|
tokens = Plume.tokenize("SELECT * FROM users WHERE id = 1")
|
|
205
232
|
# => [:SELECT, :STAR, :FROM, :ID, :WHERE, :ID, :EQ, :INTEGER]
|
|
206
233
|
```
|
|
207
234
|
|
|
208
|
-
|
|
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
|
+
```
|
|
209
312
|
|
|
210
313
|
### What does this query read?
|
|
211
314
|
|
|
@@ -227,9 +330,66 @@ reads.tables # => #<Set: {"posts", "users"}>
|
|
|
227
330
|
reads.columns # => {"posts" => #<Set: {"id", "user_id", "title"}>, "users" => #<Set: {"id", "name", "email"}>}
|
|
228
331
|
reads.projected # columns whose values can end up in the result rows
|
|
229
332
|
reads.referenced # columns only used to filter, join, sort or group
|
|
333
|
+
reads.calls # => #<Set: {"like"}>
|
|
230
334
|
```
|
|
231
335
|
|
|
232
|
-
`
|
|
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:
|
|
233
393
|
|
|
234
394
|
```ruby
|
|
235
395
|
access = Plume.access(<<~SQL, catalog: catalog)
|
|
@@ -243,13 +403,78 @@ access.reads.referenced # => {"users" => #<Set: {"id"}>, "posts" => #<Set: {"use
|
|
|
243
403
|
|
|
244
404
|
A value copied into a column being written counts as projected, just like one returned in the result rows.
|
|
245
405
|
|
|
246
|
-
|
|
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.
|
|
247
407
|
|
|
248
|
-
|
|
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:
|
|
249
409
|
|
|
250
|
-
|
|
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
|
+
```
|
|
251
419
|
|
|
252
|
-
|
|
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.
|
|
253
478
|
|
|
254
479
|
## Roadmap
|
|
255
480
|
|
|
@@ -283,20 +508,20 @@ The results are tested against SQLite's own [authorizer callback](https://www.sq
|
|
|
283
508
|
|
|
284
509
|
## Performance
|
|
285
510
|
|
|
286
|
-
`bin/benchmark` runs
|
|
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:
|
|
287
512
|
|
|
288
|
-
| Step |
|
|
289
|
-
| --- | ---: | ---: |
|
|
290
|
-
| `Plume.tokenize` | ~
|
|
291
|
-
| `Plume.parse` | ~
|
|
292
|
-
| `Node#to_sql` | ~
|
|
293
|
-
| `Plume.reads` (no catalog) | ~
|
|
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 |
|
|
294
519
|
|
|
295
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.
|
|
296
521
|
|
|
297
522
|
## Development
|
|
298
523
|
|
|
299
|
-
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. 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.
|
|
300
525
|
|
|
301
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).
|
|
302
527
|
|