plume 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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +280 -10
  3. data/lib/plume/analysis/resolver.rb +574 -0
  4. data/lib/plume/analysis.rb +135 -0
  5. data/lib/plume/ast/alter_table_statement.rb +29 -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 +19 -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 +25 -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/delete_statement.rb +28 -0
  34. data/lib/plume/ast/detach_statement.rb +13 -0
  35. data/lib/plume/ast/drop_statement.rb +21 -0
  36. data/lib/plume/ast/empty_function_argument.rb +6 -0
  37. data/lib/plume/ast/exists_expression.rb +15 -0
  38. data/lib/plume/ast/explain_statement.rb +15 -0
  39. data/lib/plume/ast/expression.rb +30 -0
  40. data/lib/plume/ast/foreign_key_action.rb +26 -0
  41. data/lib/plume/ast/foreign_key_clause.rb +50 -0
  42. data/lib/plume/ast/foreign_key_column_constraint.rb +16 -0
  43. data/lib/plume/ast/foreign_key_table_constraint.rb +20 -0
  44. data/lib/plume/ast/frame_boundary.rb +35 -0
  45. data/lib/plume/ast/frame_spec.rb +34 -0
  46. data/lib/plume/ast/function_arguments.rb +17 -0
  47. data/lib/plume/ast/function_reference.rb +29 -0
  48. data/lib/plume/ast/generated_as_column_constraint.rb +24 -0
  49. data/lib/plume/ast/indexed_column.rb +21 -0
  50. data/lib/plume/ast/insert_statement.rb +35 -0
  51. data/lib/plume/ast/join.rb +12 -0
  52. data/lib/plume/ast/join_clause.rb +15 -0
  53. data/lib/plume/ast/join_on_constraint.rb +12 -0
  54. data/lib/plume/ast/join_operator.rb +31 -0
  55. data/lib/plume/ast/join_using_constraint.rb +16 -0
  56. data/lib/plume/ast/like_expression.rb +19 -0
  57. data/lib/plume/ast/literal_expression.rb +29 -0
  58. data/lib/plume/ast/literal_false.rb +7 -0
  59. data/lib/plume/ast/literal_nil.rb +7 -0
  60. data/lib/plume/ast/literal_value.rb +18 -0
  61. data/lib/plume/ast/match_clause.rb +15 -0
  62. data/lib/plume/ast/node.rb +207 -0
  63. data/lib/plume/ast/noop_column_constraint.rb +15 -0
  64. data/lib/plume/ast/noop_table_constraint.rb +15 -0
  65. data/lib/plume/ast/not_like_expression.rb +7 -0
  66. data/lib/plume/ast/not_null_column_constraint.rb +17 -0
  67. data/lib/plume/ast/null_column_constraint.rb +17 -0
  68. data/lib/plume/ast/ordering_term.rb +32 -0
  69. data/lib/plume/ast/over_clause.rb +25 -0
  70. data/lib/plume/ast/parenthetical_expression.rb +37 -0
  71. data/lib/plume/ast/parenthetical_source.rb +19 -0
  72. data/lib/plume/ast/pragma_statement.rb +26 -0
  73. data/lib/plume/ast/primary_key_column_constraint.rb +23 -0
  74. data/lib/plume/ast/primary_key_table_constraint.rb +23 -0
  75. data/lib/plume/ast/raise_expression.rb +23 -0
  76. data/lib/plume/ast/reindex_statement.rb +15 -0
  77. data/lib/plume/ast/release_statement.rb +16 -0
  78. data/lib/plume/ast/result_column.rb +18 -0
  79. data/lib/plume/ast/returning_clause.rb +12 -0
  80. data/lib/plume/ast/rollback_statement.rb +19 -0
  81. data/lib/plume/ast/savepoint_statement.rb +16 -0
  82. data/lib/plume/ast/select_core.rb +27 -0
  83. data/lib/plume/ast/select_statement.rb +34 -0
  84. data/lib/plume/ast/star_function_argument.rb +6 -0
  85. data/lib/plume/ast/star_result_column.rb +16 -0
  86. data/lib/plume/ast/strict_table_option.rb +6 -0
  87. data/lib/plume/ast/subquery_expression.rb +14 -0
  88. data/lib/plume/ast/subquery_source.rb +19 -0
  89. data/lib/plume/ast/table_constraint.rb +7 -0
  90. data/lib/plume/ast/table_function.rb +28 -0
  91. data/lib/plume/ast/table_name.rb +28 -0
  92. data/lib/plume/ast/table_option.rb +7 -0
  93. data/lib/plume/ast/ternary_expression.rb +26 -0
  94. data/lib/plume/ast/token.rb +180 -0
  95. data/lib/plume/ast/unary_expression.rb +36 -0
  96. data/lib/plume/ast/unique_column_constraint.rb +17 -0
  97. data/lib/plume/ast/unique_table_constraint.rb +20 -0
  98. data/lib/plume/ast/update_statement.rb +35 -0
  99. data/lib/plume/ast/upsert_clause.rb +26 -0
  100. data/lib/plume/ast/vacuum_statement.rb +18 -0
  101. data/lib/plume/ast/values_clause.rb +12 -0
  102. data/lib/plume/ast/values_row.rb +13 -0
  103. data/lib/plume/ast/variable.rb +15 -0
  104. data/lib/plume/ast/window_definition.rb +26 -0
  105. data/lib/plume/ast/with_clause.rb +15 -0
  106. data/lib/plume/ast/without_rowid_table_option.rb +6 -0
  107. data/lib/plume/catalog.rb +180 -0
  108. data/lib/plume/lexer.rb +639 -0
  109. data/lib/plume/parser.rb +4015 -0
  110. data/lib/plume/printer.rb +471 -0
  111. data/lib/plume/sqlite_grammar.rb +86 -0
  112. data/lib/plume/union.rb +28 -0
  113. data/lib/plume/version.rb +1 -1
  114. data/lib/plume.rb +143 -3
  115. metadata +167 -12
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plume
4
+ # What the read analysis knows about a database: its tables with their columns, and its
5
+ # views. Names are matched case-insensitively, the way SQLite matches identifiers, and
6
+ # reported in the spelling they were declared with.
7
+ class Catalog
8
+ ROWID_NAMES = %w[rowid oid _rowid_].freeze
9
+
10
+ # `columns` is nil when they cannot be known, like a CREATE TABLE … AS SELECT * from a table
11
+ # the catalog lacks; any column name is then taken as written
12
+ Table = Data.define(:schema, :name, :columns, :hidden_columns, :rowid_alias, :without_rowid) do
13
+ def column(name)
14
+ return name.to_s unless columns
15
+
16
+ needle = name.to_s.downcase
17
+ columns.find { |c| c.downcase == needle } || hidden_columns.find { |c| c.downcase == needle }
18
+ end
19
+
20
+ def rowid? = !without_rowid
21
+ end
22
+
23
+ View = Data.define(:schema, :name, :columns, :select)
24
+
25
+ def self.from_sql(sql) = new.load_sql(sql)
26
+
27
+ # Takes a sqlite3-ruby `SQLite3::Database` (or anything with a compatible `#execute`)
28
+ def self.from_sqlite(db) = new.load_sqlite(db)
29
+
30
+ def initialize
31
+ @tables = {}
32
+ @views = {}
33
+ end
34
+
35
+ def add_table(name, columns, schema: "main", hidden_columns: [], rowid_alias: nil, without_rowid: false)
36
+ table = Table.new(schema:, name:, columns: columns.freeze, hidden_columns: hidden_columns.freeze, rowid_alias:, without_rowid:)
37
+ @tables[key(schema, name)] = table
38
+ end
39
+
40
+ # `select` is a `SelectStatement` or the SQL for one
41
+ def add_view(name, select, columns: nil, schema: "main")
42
+ select = Parser.new(select).parse.first if String === select
43
+ @views[key(schema, name)] = View.new(schema:, name:, columns: columns&.freeze, select:)
44
+ end
45
+
46
+ # Unqualified names are looked up the way SQLite does: temp first, then main,
47
+ # then attached databases in the order they were added
48
+ def table(name, schema: nil) = lookup(@tables, name, schema)
49
+ def view(name, schema: nil) = lookup(@views, name, schema)
50
+
51
+ def tables = @tables.values
52
+ def views = @views.values
53
+
54
+ # Statements other than CREATE TABLE and CREATE VIEW (indexes, triggers, inserts…) do not
55
+ # change what can be read, and are skipped without being parsed
56
+ def load_sql(sql)
57
+ Catalog.split_statements(sql).each do |statement_sql|
58
+ leading = Plume.tokenize(statement_sql).first(3) - [:TEMP, :TEMPORARY]
59
+ next unless leading[0] == :CREATE && (leading[1] in :TABLE | :VIEW)
60
+
61
+ case (statement = Parser.new(statement_sql).parse.first)
62
+ when CreateTableStatement then add_create_table(statement)
63
+ when CreateViewStatement then add_create_view(statement)
64
+ end
65
+ end
66
+ self
67
+ end
68
+
69
+ def load_sqlite(db)
70
+ db.execute("PRAGMA database_list").each do |(_seq, schema, _file)|
71
+ master = (schema == "temp") ? "sqlite_temp_master" : "#{quote(schema)}.sqlite_master"
72
+ db.execute("SELECT type, name, sql FROM #{master} WHERE type IN ('table', 'view')").each do |(type, name, sql)|
73
+ next if name.start_with?("sqlite_")
74
+
75
+ if type == "view"
76
+ statement = Parser.new(sql).parse.first
77
+ add_view(name, statement.select, columns: statement.column_names, schema:)
78
+ else
79
+ add_sqlite_table(db, schema, name, sql)
80
+ end
81
+ end
82
+ end
83
+ self
84
+ end
85
+
86
+ # Splits SQL into statements on top-level semicolons, keeping trigger bodies
87
+ # (`BEGIN …; …; END`) in one piece
88
+ def self.split_statements(sql)
89
+ lexer = Lexer.new(sql, skip_spaces: true)
90
+ statements = []
91
+ start = 0
92
+ recent = []
93
+ trigger_depth = 0
94
+ in_trigger = false
95
+
96
+ while (token = lexer.next_token)
97
+ recent = [*recent.last(2), token]
98
+ in_trigger = true if token == :TRIGGER && recent.include?(:CREATE)
99
+
100
+ if in_trigger && token == :BEGIN
101
+ trigger_depth += 1
102
+ elsif in_trigger && token == :END && trigger_depth > 0
103
+ trigger_depth -= 1
104
+ in_trigger = false if trigger_depth.zero?
105
+ elsif token == :SEMI && trigger_depth.zero?
106
+ statements << sql.byteslice(start, lexer.cursor - start)
107
+ start = lexer.cursor
108
+ end
109
+ end
110
+ statements << sql.byteslice(start, sql.bytesize - start)
111
+ statements.reject { |s| Plume.tokenize(s).reject { |t| t == :SEMI }.empty? }
112
+ end
113
+
114
+ private
115
+
116
+ def key(schema, name) = [schema.to_s.downcase, name.to_s.downcase].freeze
117
+
118
+ def lookup(collection, name, schema)
119
+ return collection[key(schema, name)] if schema
120
+
121
+ collection[key("temp", name)] || collection[key("main", name)] ||
122
+ collection.find { |(_s, n), _| n == name.to_s.downcase }&.last
123
+ end
124
+
125
+ def schema_of(name, statement) = name.schema || (statement.temporary ? "temp" : "main")
126
+
127
+ def add_create_table(statement)
128
+ schema = schema_of(statement.table, statement)
129
+ name = statement.table.name
130
+
131
+ if statement.select_statement
132
+ columns = Analysis::Resolver.new(catalog: self).output_columns(statement.select_statement)
133
+ add_table(name, (columns unless columns.include?(nil)), schema:)
134
+ return
135
+ end
136
+
137
+ columns = statement.columns.map(&:name)
138
+ add_table(name, columns, schema:, rowid_alias: rowid_alias(statement), without_rowid: statement.without_row_id)
139
+ end
140
+
141
+ def add_create_view(statement)
142
+ schema = schema_of(statement.view, statement)
143
+ add_view(statement.view.name, statement.select, columns: statement.column_names, schema:)
144
+ end
145
+
146
+ # A column becomes another name for the rowid when it is the whole primary key and is
147
+ # declared as exactly INTEGER - not INT, not BIGINT. See https://www.sqlite.org/lang_createtable.html#rowid
148
+ def rowid_alias(statement)
149
+ return if statement.without_row_id
150
+
151
+ candidate = statement.columns.find do |column|
152
+ column.constraints&.any? { |c| PrimaryKeyColumnConstraint === c && c.direction != :DESC }
153
+ end
154
+
155
+ unless candidate
156
+ table_pk = statement.constraints&.find { |c| PrimaryKeyTableConstraint === c }
157
+ if table_pk && table_pk.columns.size == 1
158
+ pk_name = table_pk.columns[0].name.downcase
159
+ candidate = statement.columns.find { |c| c.name.downcase == pk_name }
160
+ end
161
+ end
162
+
163
+ candidate.name if candidate&.type&.text&.casecmp?("INTEGER")
164
+ end
165
+
166
+ def add_sqlite_table(db, schema, name, sql)
167
+ info = db.execute("PRAGMA #{quote(schema)}.table_xinfo(#{quote(name)})")
168
+ # hidden is 0 for ordinary columns, 1 for hidden virtual table columns, 2 and 3 for generated ones
169
+ columns = info.reject { |row| row[6] == 1 }.map { |row| row[1] }
170
+ hidden = info.select { |row| row[6] == 1 }.map { |row| row[1] }
171
+ without_rowid = sql.to_s.match?(/\bWITHOUT\s+ROWID\s*;?\s*\z/i)
172
+ pk_rows = info.select { |row| row[5].to_i > 0 }
173
+ rowid_alias = pk_rows[0][1] if !without_rowid && pk_rows.size == 1 && pk_rows[0][2].to_s.casecmp?("INTEGER")
174
+
175
+ add_table(name, columns, schema:, hidden_columns: hidden, rowid_alias:, without_rowid:)
176
+ end
177
+
178
+ def quote(identifier) = %("#{identifier.to_s.gsub('"', '""')}")
179
+ end
180
+ end