finox 0.1.0 → 0.2.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.
data/README.md CHANGED
@@ -2,44 +2,49 @@
2
2
 
3
3
  A MySQL query parser for Ruby, powered by [sqlparser-rs](https://github.com/apache/datafusion-sqlparser-rs).
4
4
 
5
- Finox parses SQL with Rust and returns the AST as plain Ruby Hashes and Arrays.
5
+ Finox parses SQL with Rust and exposes tables, columns, fingerprints and the raw AST.
6
6
 
7
7
  ## Installation
8
8
 
9
- Add to your Gemfile:
10
-
11
9
  ```bash
12
10
  bundle add finox
13
11
  ```
14
12
 
15
- Or install directly:
13
+ Or without Bundler:
16
14
 
17
15
  ```bash
18
16
  gem install finox
19
17
  ```
20
18
 
21
- A Rust toolchain is required to build the native extension.
19
+ Precompiled native gems are available for Ruby 3.1–4.0 on the following
20
+ platforms, so installation there requires no Rust toolchain:
21
+
22
+ | OS | Platforms |
23
+ | ------- | ------------------------------------------------ |
24
+ | Linux | x86_64, aarch64 (glibc and musl variants) |
25
+ | macOS | x86_64, arm64 |
26
+ | Windows | x64-mingw-ucrt |
27
+
28
+ On any other platform the source gem is installed instead, and compiling it
29
+ requires a Rust toolchain.
22
30
 
23
31
  ## Usage
24
32
 
33
+ `Finox.parse` returns a `Finox::Result`:
34
+
25
35
  ```ruby
26
36
  require "finox"
27
37
 
28
- ast = Finox.parse("SELECT `name` FROM `users` WHERE id = 1")
29
- # => [{"Query" => {with: nil, body: {"Select" => {...}}, ...}}]
38
+ result = Finox.parse("SELECT `name` FROM `users` WHERE id = 1")
39
+ # => #<Finox::Result>
30
40
 
31
- ast.dig(0, "Query", :body, "Select", :projection)
32
- # => [{"UnnamedExpr" =>
33
- # {"Identifier" =>
34
- # {value: "name",
35
- # quote_style: "`",
36
- # span: {start: {line: 1, column: 8}, end: {line: 1, column: 14}}}}}]
41
+ result.tables # => ["users"]
42
+ result.columns # => ["name", "id"]
43
+ result.statement_types # => ["Query"]
44
+ result.normalize # => "SELECT `name` FROM `users` WHERE id = ?"
45
+ result.fingerprint # => "24e307ca0f02abfc"
37
46
  ```
38
47
 
39
- `Finox.parse` returns one Hash per statement, so `"SELECT 1; SELECT 2"` yields an array of two.
40
-
41
- Key convention (from serde's externally tagged enums): enum variant names are `String` keys (`"Query"`, `"Select"`, `"Identifier"`), struct fields are `Symbol` keys (`:body`, `:projection`, `:value`).
42
-
43
48
  Invalid SQL raises `Finox::ParseError`:
44
49
 
45
50
  ```ruby
@@ -47,6 +52,69 @@ Finox.parse("SELEKT 1")
47
52
  # => Finox::ParseError: sql parser error: Expected: an SQL statement, found: SELEKT at Line: 1, Column: 1
48
53
  ```
49
54
 
55
+ ### Table classification
56
+
57
+ `#select_tables`, `#dml_tables` and `#ddl_tables` classify the referenced
58
+ tables by how they are used: read, written by DML (`INSERT` / `UPDATE` /
59
+ `DELETE`) or targeted by DDL (`CREATE TABLE` / `ALTER TABLE` / `DROP TABLE` /
60
+ `TRUNCATE` etc.). A table appearing in multiple roles is listed in each.
61
+
62
+ ```ruby
63
+ result = Finox.parse("INSERT INTO logs SELECT message FROM events; DROP TABLE archives")
64
+
65
+ result.tables # => ["logs", "events", "archives"]
66
+ result.select_tables # => ["events"]
67
+ result.dml_tables # => ["logs"]
68
+ result.ddl_tables # => ["archives"]
69
+ ```
70
+
71
+ #### Known limitations
72
+
73
+ Extraction is syntactic, with no schema knowledge and only coarse scope
74
+ resolution:
75
+
76
+ - A CTE name shadows a same-named real table everywhere in the statement,
77
+ even inside the CTE's own definition.
78
+ - Multi-table `UPDATE` (`UPDATE t1 JOIN t2 ... SET t2.x = 1`) reports only
79
+ `t1` in `#dml_tables`.
80
+ - `#columns` does not resolve table aliases (`u.id` stays `u.id`).
81
+
82
+ ### Normalization and fingerprints
83
+
84
+ `#normalize` replaces literals with `?` placeholders and deparses from the
85
+ AST, so formatting and keyword case are normalized as well. `#fingerprint` is
86
+ a 64-bit hex hash (xxhash64) of the normalized SQL — stable across literal
87
+ and formatting differences, but not guaranteed to be stable across finox
88
+ versions, since the normalized form depends on the bundled sqlparser-rs.
89
+ Recompute stored fingerprints when upgrading finox.
90
+
91
+ ```ruby
92
+ Finox.parse("select * from users where id=1").normalize
93
+ # => "SELECT * FROM users WHERE id = ?"
94
+
95
+ Finox.parse("select * from users where id=1").fingerprint ==
96
+ Finox.parse("SELECT * FROM users WHERE id = 42").fingerprint
97
+ # => true
98
+ ```
99
+
100
+ ### Raw sqlparser-rs AST
101
+
102
+ For anything not covered above, `#statements` exposes sqlparser-rs's raw AST of each parsed statement as plain
103
+ Ruby Hashes and Arrays:
104
+
105
+ ```ruby
106
+ ast = Finox.parse("SELECT `name` FROM `users` WHERE id = 1").statements.first
107
+ # => {"Query" => {"with" => nil, "body" => {"Select" => {...}}, ...}}
108
+
109
+ ast.dig("Query", "body", "Select", "projection")
110
+ # => [{"UnnamedExpr" =>
111
+ # {"Identifier" =>
112
+ # {"value" => "name",
113
+ # "quote_style" => "`",
114
+ # "span" => {"start" => {"line" => 1, "column" => 8},
115
+ # "end" => {"line" => 1, "column" => 14}}}}}]
116
+ ```
117
+
50
118
  ## Development
51
119
 
52
120
  ```bash
@@ -58,3 +126,7 @@ bundle exec rake # compile + spec + rubocop
58
126
  ## License
59
127
 
60
128
  [MIT](LICENSE.txt)
129
+
130
+ Precompiled gems statically link Rust crates, including sqlparser-rs
131
+ (Apache-2.0). See [LICENSE-THIRD-PARTY.txt](LICENSE-THIRD-PARTY.txt) for
132
+ their licenses and attributions.
data/ext/finox/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "finox"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  edition = "2021"
5
5
  authors = ["kyuuri1791"]
6
6
  license = "MIT"
@@ -11,5 +11,6 @@ crate-type = ["cdylib"]
11
11
 
12
12
  [dependencies]
13
13
  magnus = "0.8"
14
- serde_magnus = "0.11.0"
15
- sqlparser = { version = "0.62.0", features = ["serde"] }
14
+ serde_json = { version = "1", features = ["preserve_order"] }
15
+ sqlparser = { version = "0.62.0", features = ["serde", "visitor"] }
16
+ twox-hash = { version = "2.1", default-features = false, features = ["xxhash64"] }
data/ext/finox/src/lib.rs CHANGED
@@ -1,5 +1,16 @@
1
- use magnus::{function, prelude::*, value::Lazy, Error, ExceptionClass, Ruby, Value};
1
+ use std::collections::HashSet;
2
+ use std::fmt::{self, Write as _};
3
+ use std::ops::ControlFlow;
4
+
5
+ use magnus::{
6
+ function, method, prelude::*, value::Lazy, Error, ExceptionClass, IntoValue, Ruby, Value,
7
+ };
8
+ use sqlparser::ast::{
9
+ visit_expressions_mut, AssignmentTarget, Expr, FromTable, ObjectName, ObjectType, Query,
10
+ Statement, TableFactor, TableObject, TableWithJoins, Value as SqlValue, Visit, Visitor,
11
+ };
2
12
  use sqlparser::{dialect::MySqlDialect, parser::Parser};
13
+ use twox_hash::XxHash64;
3
14
 
4
15
  static PARSE_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
5
16
  ruby.define_module("Finox")
@@ -8,16 +19,364 @@ static PARSE_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
8
19
  .unwrap()
9
20
  });
10
21
 
11
- fn parse(ruby: &Ruby, sql: String) -> Result<Value, Error> {
22
+ #[magnus::wrap(class = "Finox::Result", free_immediately, size)]
23
+ struct ParseResult {
24
+ statements: Vec<Statement>,
25
+ }
26
+
27
+ impl ParseResult {
28
+ fn statements(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
29
+ let json = serde_json::to_value(&rb_self.statements)
30
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
31
+ json_to_ruby(ruby, &json)
32
+ }
33
+
34
+ fn tables(&self) -> Vec<String> {
35
+ dedup(self.statements.iter().flat_map(collect_tables).collect())
36
+ }
37
+
38
+ fn select_tables(&self) -> Vec<String> {
39
+ dedup(self.statements.iter().flat_map(collect_select_tables).collect())
40
+ }
41
+
42
+ fn dml_tables(&self) -> Vec<String> {
43
+ dedup(self.statements.iter().flat_map(collect_dml_tables).collect())
44
+ }
45
+
46
+ fn ddl_tables(&self) -> Vec<String> {
47
+ dedup(self.statements.iter().flat_map(collect_ddl_tables).collect())
48
+ }
49
+
50
+ fn columns(&self) -> Vec<String> {
51
+ collect_columns(&self.statements)
52
+ }
53
+
54
+ fn statement_types(&self) -> Vec<String> {
55
+ self.statements.iter().map(statement_type).collect()
56
+ }
57
+
58
+ fn normalize(&self) -> String {
59
+ self.statements
60
+ .iter()
61
+ .map(normalize_statement)
62
+ .collect::<Vec<_>>()
63
+ .join("; ")
64
+ }
65
+
66
+ fn fingerprint(&self) -> String {
67
+ fingerprint(&self.normalize())
68
+ }
69
+ }
70
+
71
+ fn fingerprint(sql: &str) -> String {
72
+ format!("{:016x}", XxHash64::oneshot(0, sql.as_bytes()))
73
+ }
74
+
75
+ fn json_to_ruby(ruby: &Ruby, value: &serde_json::Value) -> Result<Value, Error> {
76
+ use serde_json::Value as Json;
77
+
78
+ Ok(match value {
79
+ Json::Null => ruby.qnil().as_value(),
80
+ Json::Bool(boolean) => (*boolean).into_value_with(ruby),
81
+ Json::Number(number) => {
82
+ if let Some(int) = number.as_i64() {
83
+ int.into_value_with(ruby)
84
+ } else if let Some(int) = number.as_u64() {
85
+ int.into_value_with(ruby)
86
+ } else {
87
+ number.as_f64().into_value_with(ruby)
88
+ }
89
+ }
90
+ Json::String(string) => string.as_str().into_value_with(ruby),
91
+ Json::Array(items) => {
92
+ let array = ruby.ary_new_capa(items.len());
93
+ for item in items {
94
+ array.push(json_to_ruby(ruby, item)?)?;
95
+ }
96
+ array.as_value()
97
+ }
98
+ Json::Object(map) => {
99
+ let hash = ruby.hash_new();
100
+ for (key, item) in map {
101
+ hash.aset(key.as_str(), json_to_ruby(ruby, item)?)?;
102
+ }
103
+ hash.as_value()
104
+ }
105
+ })
106
+ }
107
+
108
+ fn normalize_statement(statement: &Statement) -> String {
109
+ let mut statement = statement.clone();
110
+ let _ = visit_expressions_mut(&mut statement, |expr| {
111
+ if let Expr::Value(value) = expr {
112
+ value.value = SqlValue::Placeholder("?".to_string());
113
+ }
114
+ ControlFlow::<()>::Continue(())
115
+ });
116
+ statement.to_string()
117
+ }
118
+
119
+ fn statement_type(statement: &Statement) -> String {
120
+ struct VariantName(String);
121
+
122
+ impl fmt::Write for VariantName {
123
+ fn write_str(&mut self, s: &str) -> fmt::Result {
124
+ for ch in s.chars() {
125
+ if ch.is_alphanumeric() || ch == '_' {
126
+ self.0.push(ch);
127
+ } else {
128
+ return Err(fmt::Error);
129
+ }
130
+ }
131
+ Ok(())
132
+ }
133
+ }
134
+
135
+ let mut name = VariantName(String::new());
136
+ let _ = write!(name, "{statement:?}");
137
+ name.0
138
+ }
139
+
140
+ fn table_info<T: Visit>(node: &T) -> TableCollector {
141
+ let mut collector = TableCollector::default();
142
+ let _ = node.visit(&mut collector);
143
+ collector
144
+ }
145
+
146
+ fn collect_tables<T: Visit>(node: &T) -> Vec<String> {
147
+ let info = table_info(node);
148
+ let mut names = info.relations;
149
+ names.extend(info.dml_targets);
150
+ names.extend(info.ddl_targets);
151
+ dedup(reject_cte_names(names, &info.cte_names))
152
+ }
153
+
154
+ fn collect_select_tables<T: Visit>(node: &T) -> Vec<String> {
155
+ let info = table_info(node);
156
+ let mut targets = info.dml_targets;
157
+ targets.extend(info.ddl_targets);
158
+
159
+ let mut names = Vec::new();
160
+ for name in info.relations {
161
+ match targets.iter().position(|target| *target == name) {
162
+ Some(position) => {
163
+ targets.remove(position);
164
+ }
165
+ None => names.push(name),
166
+ }
167
+ }
168
+ dedup(reject_cte_names(names, &info.cte_names))
169
+ }
170
+
171
+ fn collect_dml_tables<T: Visit>(node: &T) -> Vec<String> {
172
+ dedup(table_info(node).dml_targets)
173
+ }
174
+
175
+ fn collect_ddl_tables<T: Visit>(node: &T) -> Vec<String> {
176
+ dedup(table_info(node).ddl_targets)
177
+ }
178
+
179
+ fn reject_cte_names(names: Vec<String>, cte_names: &HashSet<String>) -> Vec<String> {
180
+ names
181
+ .into_iter()
182
+ .filter(|name| !cte_names.contains(name))
183
+ .collect()
184
+ }
185
+
186
+ fn collect_columns<T: Visit>(node: &T) -> Vec<String> {
187
+ let mut collector = ColumnCollector::default();
188
+ let _ = node.visit(&mut collector);
189
+ dedup(collector.columns)
190
+ }
191
+
192
+ fn dedup(names: Vec<String>) -> Vec<String> {
193
+ let mut result = Vec::new();
194
+ for name in names {
195
+ if !result.contains(&name) {
196
+ result.push(name);
197
+ }
198
+ }
199
+ result
200
+ }
201
+
202
+ fn object_name_to_string(name: &ObjectName) -> String {
203
+ name.0
204
+ .iter()
205
+ .map(|part| match part.as_ident() {
206
+ Some(ident) => ident.value.clone(),
207
+ None => part.to_string(),
208
+ })
209
+ .collect::<Vec<_>>()
210
+ .join(".")
211
+ }
212
+
213
+ fn table_with_joins_relation(table: &TableWithJoins) -> Option<String> {
214
+ match &table.relation {
215
+ TableFactor::Table { name, .. } => Some(object_name_to_string(name)),
216
+ _ => None,
217
+ }
218
+ }
219
+
220
+ #[derive(Default)]
221
+ struct TableCollector {
222
+ relations: Vec<String>,
223
+ dml_targets: Vec<String>,
224
+ ddl_targets: Vec<String>,
225
+ cte_names: HashSet<String>,
226
+ }
227
+
228
+ impl Visitor for TableCollector {
229
+ type Break = ();
230
+
231
+ fn pre_visit_relation(&mut self, relation: &ObjectName) -> ControlFlow<()> {
232
+ self.relations.push(object_name_to_string(relation));
233
+ ControlFlow::Continue(())
234
+ }
235
+
236
+ fn pre_visit_query(&mut self, query: &Query) -> ControlFlow<()> {
237
+ if let Some(with) = &query.with {
238
+ for cte in &with.cte_tables {
239
+ self.cte_names.insert(cte.alias.name.value.clone());
240
+ }
241
+ }
242
+ ControlFlow::Continue(())
243
+ }
244
+
245
+ fn pre_visit_statement(&mut self, statement: &Statement) -> ControlFlow<()> {
246
+ match statement {
247
+ Statement::Insert(insert) => {
248
+ if let TableObject::TableName(name) = &insert.table {
249
+ self.dml_targets.push(object_name_to_string(name));
250
+ }
251
+ }
252
+ Statement::Update(update) => {
253
+ if let Some(name) = table_with_joins_relation(&update.table) {
254
+ self.dml_targets.push(name);
255
+ }
256
+ }
257
+ Statement::Delete(delete) => {
258
+ if delete.tables.is_empty() {
259
+ let (FromTable::WithFromKeyword(from) | FromTable::WithoutKeyword(from)) =
260
+ &delete.from;
261
+ for table in from {
262
+ if let Some(name) = table_with_joins_relation(table) {
263
+ self.dml_targets.push(name);
264
+ }
265
+ }
266
+ } else {
267
+ for name in &delete.tables {
268
+ self.dml_targets.push(object_name_to_string(name));
269
+ }
270
+ }
271
+ }
272
+ Statement::CreateTable(create) => {
273
+ self.ddl_targets.push(object_name_to_string(&create.name));
274
+ }
275
+ Statement::AlterTable(alter) => {
276
+ self.ddl_targets.push(object_name_to_string(&alter.name));
277
+ }
278
+ Statement::CreateIndex(index) => {
279
+ self.ddl_targets
280
+ .push(object_name_to_string(&index.table_name));
281
+ }
282
+ Statement::Drop {
283
+ object_type: ObjectType::Table,
284
+ names,
285
+ ..
286
+ } => {
287
+ for name in names {
288
+ self.ddl_targets.push(object_name_to_string(name));
289
+ }
290
+ }
291
+ Statement::Truncate(truncate) => {
292
+ for target in &truncate.table_names {
293
+ self.ddl_targets.push(object_name_to_string(&target.name));
294
+ }
295
+ }
296
+ Statement::RenameTable(renames) => {
297
+ for rename in renames {
298
+ self.ddl_targets.push(object_name_to_string(&rename.old_name));
299
+ self.ddl_targets.push(object_name_to_string(&rename.new_name));
300
+ }
301
+ }
302
+ _ => {}
303
+ }
304
+ ControlFlow::Continue(())
305
+ }
306
+ }
307
+
308
+ #[derive(Default)]
309
+ struct ColumnCollector {
310
+ columns: Vec<String>,
311
+ }
312
+
313
+ impl Visitor for ColumnCollector {
314
+ type Break = ();
315
+
316
+ fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
317
+ match expr {
318
+ Expr::Identifier(ident) => self.columns.push(ident.value.clone()),
319
+ Expr::CompoundIdentifier(idents) => self.columns.push(
320
+ idents
321
+ .iter()
322
+ .map(|ident| ident.value.clone())
323
+ .collect::<Vec<_>>()
324
+ .join("."),
325
+ ),
326
+ _ => {}
327
+ }
328
+ ControlFlow::Continue(())
329
+ }
330
+
331
+ fn pre_visit_statement(&mut self, statement: &Statement) -> ControlFlow<()> {
332
+ match statement {
333
+ Statement::Insert(insert) => {
334
+ for column in &insert.columns {
335
+ self.columns.push(object_name_to_string(column));
336
+ }
337
+ }
338
+ Statement::Update(update) => {
339
+ for assignment in &update.assignments {
340
+ match &assignment.target {
341
+ AssignmentTarget::ColumnName(name) => {
342
+ self.columns.push(object_name_to_string(name));
343
+ }
344
+ AssignmentTarget::Tuple(names) => {
345
+ for name in names {
346
+ self.columns.push(object_name_to_string(name));
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+ _ => {}
353
+ }
354
+ ControlFlow::Continue(())
355
+ }
356
+ }
357
+
358
+ fn parse(ruby: &Ruby, sql: String) -> Result<ParseResult, Error> {
12
359
  let statements = Parser::parse_sql(&MySqlDialect {}, &sql)
13
360
  .map_err(|e| Error::new(ruby.get_inner(&PARSE_ERROR), e.to_string()))?;
14
- serde_magnus::serialize(ruby, &statements)
361
+ Ok(ParseResult { statements })
15
362
  }
16
363
 
17
364
  #[magnus::init]
18
365
  fn init(ruby: &Ruby) -> Result<(), Error> {
19
366
  let module = ruby.define_module("Finox")?;
20
367
  Lazy::force(&PARSE_ERROR, ruby);
368
+
369
+ let result = module.define_class("Result", ruby.class_object())?;
370
+ result.define_method("statements", method!(ParseResult::statements, 0))?;
371
+ result.define_method("tables", method!(ParseResult::tables, 0))?;
372
+ result.define_method("select_tables", method!(ParseResult::select_tables, 0))?;
373
+ result.define_method("dml_tables", method!(ParseResult::dml_tables, 0))?;
374
+ result.define_method("ddl_tables", method!(ParseResult::ddl_tables, 0))?;
375
+ result.define_method("columns", method!(ParseResult::columns, 0))?;
376
+ result.define_method("statement_types", method!(ParseResult::statement_types, 0))?;
377
+ result.define_method("normalize", method!(ParseResult::normalize, 0))?;
378
+ result.define_method("fingerprint", method!(ParseResult::fingerprint, 0))?;
379
+
21
380
  module.define_singleton_method("parse", function!(parse, 1))?;
22
381
  Ok(())
23
382
  }
data/lib/finox/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Finox
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: finox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyuuri1791
@@ -30,11 +30,10 @@ extensions:
30
30
  - ext/finox/extconf.rb
31
31
  extra_rdoc_files: []
32
32
  files:
33
- - ".rspec"
34
- - ".rubocop.yml"
35
33
  - CHANGELOG.md
36
34
  - Cargo.lock
37
35
  - Cargo.toml
36
+ - LICENSE-THIRD-PARTY.txt
38
37
  - LICENSE.txt
39
38
  - README.md
40
39
  - Rakefile
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,8 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.1
3
-
4
- Style/StringLiterals:
5
- EnforcedStyle: double_quotes
6
-
7
- Style/StringLiteralsInInterpolation:
8
- EnforcedStyle: double_quotes