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.
@@ -3,21 +3,30 @@
3
3
  module Plume
4
4
  # **[SQLite Docs](https://www.sqlite.org/lang_altertable.html)**
5
5
  #
6
- # `action` is :RENAME_TABLE, :RENAME_COLUMN, :ADD_COLUMN or :DROP_COLUMN
6
+ # `action` is :RENAME_TABLE, :RENAME_COLUMN, :ADD_COLUMN, :DROP_COLUMN, :ADD_CONSTRAINT (a CHECK),
7
+ # :DROP_CONSTRAINT, :SET_NOT_NULL or :DROP_NOT_NULL
7
8
  class AlterTableStatement < Node
8
- Action = _Union(:RENAME_TABLE, :RENAME_COLUMN, :ADD_COLUMN, :DROP_COLUMN)
9
+ Action = _Union(
10
+ :RENAME_TABLE, :RENAME_COLUMN, :ADD_COLUMN, :DROP_COLUMN,
11
+ :ADD_CONSTRAINT, :DROP_CONSTRAINT, :SET_NOT_NULL, :DROP_NOT_NULL,
12
+ )
9
13
 
10
14
  token :alter_table_kw
11
- token :action_kw # RENAME [COLUMN], ADD [COLUMN] or DROP [COLUMN]
15
+ token :action_kw # RENAME [COLUMN], ADD [COLUMN], DROP [COLUMN], DROP CONSTRAINT or ALTER [COLUMN]
12
16
  token :column_tk
13
17
  token :to_kw
14
18
  token :new_name_tk
19
+ token :constraint_name_tk
20
+ token :not_null_kw # SET NOT NULL or DROP NOT NULL
15
21
 
16
22
  node :table, TableName
17
23
  attribute :action, Action
18
24
  node :column_definition, ColumnDefinition
25
+ node :constraint, CheckTableConstraint
26
+ node :conflict_clause, ConflictClause
19
27
  attribute :column, Stringy
20
28
  attribute :new_name, Stringy
29
+ attribute :constraint_name, Stringy
21
30
 
22
31
  def self.new(*, table:, action:, **) = super
23
32
  def self.concrete(*, alter_table_kw:, table:, action:, **) = super
@@ -25,5 +34,7 @@ module Plume
25
34
  def action = @action
26
35
  def column = (@column == LiteralNil) ? column_tk_val : @column
27
36
  def new_name = (@new_name == LiteralNil) ? new_name_tk_val : @new_name
37
+ # The constraint dropped, or added - which keeps its name on `constraint`
38
+ def constraint_name = (@constraint_name == LiteralNil) ? (constraint_name_tk_val || constraint&.name) : @constraint_name
28
39
  end
29
40
  end
@@ -10,6 +10,7 @@ module Plume
10
10
 
11
11
  attribute :name, Stringy
12
12
  node :expression, Expression
13
+ node :conflict_clause, ConflictClause
13
14
 
14
15
  def self.new(*, expression:, **) = super
15
16
  def self.concrete(*, expression:, **) = super
@@ -14,6 +14,6 @@ module Plume
14
14
  def self.concrete(*, collate_kw:, collation_tk:, **) = super
15
15
 
16
16
  def name = (@name == LiteralNil) ? name_tk_val : @name
17
- def collation = (@collation == LiteralNil) ? collation_tk_val&.to_sym&.upcase : @collation
17
+ def collation = (@collation == LiteralNil) ? Node.collation_name(collation_tk_val) : @collation
18
18
  end
19
19
  end
@@ -21,6 +21,6 @@ module Plume
21
21
  end
22
22
 
23
23
  def expression = (@expression == LiteralNil) ? expression_tk_val : @expression
24
- def sequence = (@sequence == LiteralNil) ? sequence_tk_val.to_sym.upcase : @sequence
24
+ def sequence = (@sequence == LiteralNil) ? Node.collation_name(sequence_tk_val) : @sequence
25
25
  end
26
26
  end
@@ -13,7 +13,8 @@ module Plume
13
13
 
14
14
  inspectable def affinity
15
15
  # rules found at: https://www.sqlite.org/datatype3.html#determination_of_column_affinity
16
- case text
16
+ # Matched as bytes, as SQLite does, so a type name that is not valid UTF-8 has one too
17
+ case text&.b
17
18
  when /INT/i then :INTEGER
18
19
  when /CHAR|CLOB|TEXT/i then :TEXT
19
20
  when /BLOB/i then :BLOB
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plume
4
+ # `[NOT] DEFERRABLE [INITIALLY DEFERRED | INITIALLY IMMEDIATE]` on its own among a column's
5
+ # constraints, where SQLite's grammar allows it (`ccons ::= defer_subclause`)
6
+ class DeferrableColumnConstraint < ColumnConstraint
7
+ token :constraint_kw
8
+ token :name_tk
9
+ token :deferrable_span
10
+
11
+ attribute :name, Stringy
12
+ attribute :deferred, ForeignKeyClause::Deferrable
13
+
14
+ def self.new(*, **) = super
15
+ def self.concrete(*, deferrable_span:, **) = super
16
+
17
+ def name = (@name == LiteralNil) ? name_tk_val : @name
18
+ def deferred = (@deferred == LiteralNil) ? ForeignKeyClause::TOKEN_TO_DEFERRABLE[deferrable_span_tok] : @deferred
19
+ end
20
+ end
@@ -12,6 +12,7 @@ module Plume
12
12
  def self.new(*, expressions:, **) = super
13
13
  def self.concrete(*, expressions:, **) = super
14
14
 
15
- def distinct = (@distinct == LiteralNil) ? distinct_kw? : @distinct
15
+ # ALL is the default, written out - only DISTINCT makes the aggregate distinct
16
+ def distinct = (@distinct == LiteralNil) ? (distinct_kw_tok == :DISTINCT) : @distinct
16
17
  end
17
18
  end
@@ -6,16 +6,19 @@ module Plume
6
6
  token :collate_kw
7
7
  token :collation_tk
8
8
  token :direction_tk
9
+ token :nulls_tk
9
10
 
10
11
  attribute :name, Stringy
11
12
  attribute :collation, Stringy
12
13
  attribute :direction, Stringy
14
+ attribute :nulls, OrderingTerm::Nulls
13
15
 
14
16
  def self.new(*, name:, **) = super
15
17
  def self.concrete(*, name_tk:, **) = super
16
18
 
17
19
  def name = (@name == LiteralNil) ? name_tk_val : @name
18
- def collation = (@collation == LiteralNil) ? collation_tk_val&.to_sym&.upcase : @collation
20
+ def collation = (@collation == LiteralNil) ? Node.collation_name(collation_tk_val) : @collation
19
21
  def direction = (@direction == LiteralNil) ? direction_tk_val&.to_sym&.upcase : @direction
22
+ def nulls = (@nulls == LiteralNil) ? nulls_tk_tok&.last : @nulls
20
23
  end
21
24
  end
@@ -6,10 +6,38 @@ module Plume
6
6
 
7
7
  Stringy = _Union(String, Symbol)
8
8
 
9
+ def self.literal_properties
10
+ super
11
+ rescue => error
12
+ raise unless defined?(Ractor::IsolationError) && Ractor::IsolationError === error
13
+
14
+ raise Ractor::IsolationError, "#{name} is not shareable yet, call Plume.eager_load! from the main Ractor first " \
15
+ "(or #{name}.make_shareable! for a class defined after it)"
16
+ end
17
+
18
+ def self.make_shareable!
19
+ Plume.make_shareable(literal_properties)
20
+ Plume.make_shareable(@inspectable_props)
21
+ Plume.make_shareable(@token_names)
22
+ self
23
+ end
24
+
25
+ def self.prop(name, ...)
26
+ raise FrozenError, "cannot add #{name} to #{self.name} after Plume.eager_load! has frozen its props" if literal_properties.frozen?
27
+
28
+ super
29
+ end
30
+
31
+ singleton_class.prepend(LiteralPatches::NodeSchema) if LiteralPatches::SCHEMA_HOLDS_MUTEX
32
+
9
33
  prop :full_source, _Nilable(String)
10
34
  prop :leading, _Nilable(Token)
11
35
  prop :trailing, _Nilable(Token)
12
36
 
37
+ # A collation's name, upcased as a symbol. A symbol cannot hold bytes that
38
+ # are not valid UTF-8, so a name with those stays the string it was
39
+ def self.collation_name(name) = name&.valid_encoding? ? name.to_sym.upcase : name
40
+
13
41
  def self.inspectable(*props)
14
42
  @inspectable_props ||= Set.new
15
43
  @inspectable_props.merge(props)
@@ -65,16 +93,94 @@ module Plume
65
93
  end
66
94
  end
67
95
 
96
+ # Yields each direct child with the prop it sits in, and its index when that prop holds an array
97
+ def each_child
98
+ # Each as one array: TruffleRuby's Enumerator hands only the first of several yielded values to `count`
99
+ return Enumerator.new { |y| each_child { |*slot| y << slot } } unless block_given?
100
+
101
+ self.class.literal_properties.each do |property|
102
+ case (value = instance_variable_get(:"@#{property.name}"))
103
+ when Node then yield value, property.name, nil
104
+ when Array then value.each_with_index { |v, i| yield v, property.name, i if Node === v }
105
+ end
106
+ end
107
+ self
108
+ end
109
+
68
110
  # Yields every node in the subtree depth-first, parents before their children,
69
- # along with the parent (nil for the receiver)
70
- def walk(parent = nil, &block)
71
- return enum_for(:walk, parent) unless block
111
+ # along with the parent (nil for the receiver). Without recursing, so a tree as
112
+ # deep as SQLite allows does not run out of stack
113
+ def walk(parent = nil)
114
+ return enum_for(:walk, parent) unless block_given?
115
+
116
+ stack = [[self, parent]]
117
+ while (node, up = stack.pop)
118
+ yield node, up
119
+ node.children.reverse_each { |child| stack << [child, node] }
120
+ end
121
+ self
122
+ end
72
123
 
73
- yield self, parent
74
- children.each { |child| child.walk(self, &block) }
124
+ # Like `walk`, but also says where under the parent each node sits: the prop's name,
125
+ # and the index into it when it holds an array
126
+ def walk_with_slots(parent = nil)
127
+ return Enumerator.new { |y| walk_with_slots(parent) { |*slot| y << slot } } unless block_given?
128
+
129
+ stack = [[self, parent, nil, nil]]
130
+ while (node, up, prop, index = stack.pop)
131
+ yield node, up, prop, index
132
+ start = stack.size
133
+ node.each_child { |child, name, i| stack << [child, node, name, i] }
134
+ stack[start..] = stack[start..].reverse
135
+ end
75
136
  self
76
137
  end
77
138
 
139
+ # A copy of the tree with nodes replaced. The block sees every node as `walk_with_slots` does,
140
+ # and returns it to keep it, or another node to put in its place - which is not descended into.
141
+ # Nodes on the way to a replacement are copied, everything else is shared with the original,
142
+ # which stays as it was. A replacement built with `.new` has no source, so print the result
143
+ # with `to_sql` rather than reading its `source_text`
144
+ def transform
145
+ replaced = {}.compare_by_identity
146
+ kept = []
147
+ stack = [[self, nil, nil, nil]]
148
+ while (node, up, prop, index = stack.pop)
149
+ replacement = yield node, up, prop, index
150
+ if replacement.equal?(node)
151
+ kept << node
152
+ node.each_child.reverse_each { |child, name, i| stack << [child, node, name, i] }
153
+ else
154
+ replaced[node] = replacement
155
+ end
156
+ end
157
+
158
+ # Parents come before their children in `kept`, so going backwards rebuilds children first
159
+ kept.reverse_each do |node|
160
+ changes = {}
161
+ node.each_child do |child, name, i|
162
+ next unless replaced.key?(child)
163
+
164
+ changes[name] ||= node.instance_variable_get(:"@#{name}").dup if i
165
+ i ? changes[name][i] = replaced[child] : changes[name] = replaced[child]
166
+ end
167
+ replaced[node] = node.with_props(changes) unless changes.empty?
168
+ end
169
+ replaced.fetch(self, self)
170
+ end
171
+
172
+ # A copy with some props set to new values, checked against their types
173
+ def with_props(changes)
174
+ copy = dup
175
+ changes.each do |name, value|
176
+ property = self.class.literal_properties[name]
177
+ raise TypeError, "#{self.class.name}##{name} cannot be #{value.inspect}" unless property.type === value
178
+
179
+ copy.instance_variable_set(:"@#{name}", value)
180
+ end
181
+ copy
182
+ end
183
+
78
184
  def each_node(&block)
79
185
  return enum_for(:each_node) unless block
80
186
 
@@ -93,7 +199,23 @@ module Plume
93
199
  [beg, fin].freeze if beg
94
200
  end
95
201
 
96
- def to_sql = Printer.print(self)
202
+ # `pretty: true` lays the SQL out over lines, see PrettyPrinter for `indent_size:`, `max_line_length:`
203
+ # and `commas_start_of_line:`. With `comments: true`, the comments right before and after the node
204
+ # in its source come along - a header above a statement, or the sqlcommenter tags after it. Those
205
+ # inside it are left out. A `--` comment after it becomes a `/* */` one, so a semicolon after it is
206
+ # not swallowed
207
+ def to_sql(pretty: false, comments: false, **layout)
208
+ sql = pretty ? PrettyPrinter.print(self, **layout) : Printer.print(self)
209
+ return sql unless comments
210
+
211
+ before, after = Comments.around(self)
212
+ lead = before.map { |c| c.text + (c.block? ? " " : "\n") }.join
213
+ tail = after.map { |c| " #{((c.block? || c.body.include?("*/")) ? c.text : "/*#{c.body} */")}" }.join
214
+ lead + sql + tail
215
+ end
216
+
217
+ # A plain hash of the whole tree that JSON can carry, which `Plume.load` turns back into it
218
+ def dump = Dump.dump(self)
97
219
 
98
220
  def source_text
99
221
  return unless @full_source && (loc = location)
@@ -195,13 +317,14 @@ module Plume
195
317
  define_node_reader(name)
196
318
  end
197
319
 
198
- # Absent children read as nil rather than the LiteralNil default
320
+ # Absent children read as nil rather than the LiteralNil default. Evaluated from a string
321
+ # because a method defined with a block cannot be called from another Ractor
199
322
  def self.define_node_reader(name)
200
- ivar = :"@#{name}"
201
- define_method(name) do
202
- value = instance_variable_get(ivar)
203
- LiteralNil.equal?(value) ? nil : value
204
- end
323
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
324
+ def #{name}
325
+ LiteralNil.equal?(@#{name}) ? nil : @#{name}
326
+ end
327
+ RUBY
205
328
  end
206
329
  end
207
330
  end
@@ -25,7 +25,7 @@ module Plume
25
25
  def self.new(*, **) = super
26
26
  def self.concrete(*, **) = super
27
27
 
28
- def collation = (@collation == LiteralNil) ? collation_tk_val&.to_sym&.upcase : @collation
28
+ def collation = (@collation == LiteralNil) ? Node.collation_name(collation_tk_val) : @collation
29
29
  def direction = (@direction == LiteralNil) ? direction_tk_val&.to_sym&.upcase : @direction
30
30
  def nulls = (@nulls == LiteralNil) ? nulls_tk_val&.to_sym&.upcase : @nulls
31
31
  end
@@ -148,9 +148,12 @@ module Plume
148
148
  src = source(str, tup)
149
149
  bytesize = src.bytesize
150
150
  # strip the leading and trailing quotes, a quote inside is written twice
151
- src.byteslice(1, bytesize - 2).gsub("''", "'")
151
+ undoubled(src.byteslice(1, bytesize - 2), "'")
152
152
  end
153
153
 
154
+ # Done on the bytes, since TruffleRuby's gsub refuses a string that is not valid UTF-8
155
+ def undoubled(text, quote) = text.b.gsub(quote * 2, quote).force_encoding(text.encoding)
156
+
154
157
  def blob_literal(str, tup)
155
158
  src = source(str, tup)
156
159
  bytesize = src.bytesize
@@ -169,9 +172,9 @@ module Plume
169
172
 
170
173
  # Inside quotes, the quote character itself is written twice. Brackets have no escape
171
174
  case src.getbyte(0)
172
- when 96 then src.byteslice(1, src.bytesize - 2).gsub("``", "`")
173
- when 39 then src.byteslice(1, src.bytesize - 2).gsub("''", "'")
174
- when 34 then src.byteslice(1, src.bytesize - 2).gsub('""', '"')
175
+ when 96 then undoubled(src.byteslice(1, src.bytesize - 2), "`")
176
+ when 39 then undoubled(src.byteslice(1, src.bytesize - 2), "'")
177
+ when 34 then undoubled(src.byteslice(1, src.bytesize - 2), '"')
175
178
  when 91 then src.byteslice(1, src.bytesize - 2)
176
179
  else src
177
180
  end
data/lib/plume/catalog.rb CHANGED
@@ -1,39 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
+ # What the read analysis knows about a database: its tables with their columns, its views,
5
+ # and which table each index and trigger belongs to. Names are matched case-insensitively, the way SQLite matches identifiers, and
6
6
  # reported in the spelling they were declared with.
7
7
  class Catalog
8
8
  ROWID_NAMES = %w[rowid oid _rowid_].freeze
9
9
 
10
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
11
+ # the catalog lacks; any column name is then taken as written. `generated_columns` are among
12
+ # `columns` too, since they can be read like any other, but nothing can write to them.
13
+ # `module_name` is set for a virtual table, like "fts5"
14
+ Table = Data.define(:schema, :name, :columns, :hidden_columns, :generated_columns, :rowid_alias, :without_rowid, :module_name) do
13
15
  def column(name)
14
16
  return name.to_s unless columns
15
17
 
16
- needle = name.to_s.downcase
17
- columns.find { |c| c.downcase == needle } || hidden_columns.find { |c| c.downcase == needle }
18
+ needle = name.to_s.downcase(:ascii)
19
+ columns.find { |c| c.downcase(:ascii) == needle } || hidden_columns.find { |c| c.downcase(:ascii) == needle }
18
20
  end
19
21
 
20
22
  def rowid? = !without_rowid
21
23
  end
22
24
 
23
25
  View = Data.define(:schema, :name, :columns, :select)
26
+ Index = Data.define(:schema, :name, :table)
27
+ Trigger = Data.define(:schema, :name, :table)
24
28
 
25
29
  def self.from_sql(sql) = new.load_sql(sql)
26
30
 
31
+ # Whether two names are one to SQLite, which folds the case of A-Z only. Compared as bytes, so a
32
+ # name that is not valid UTF-8 compares too - JRuby's casecmp? raises for one
33
+ def self.same_name?(a, b) = !a.nil? && !b.nil? && a.to_s.b.casecmp?(b.to_s.b)
34
+
35
+ # The indexes SQLite makes for a table's UNIQUE and PRIMARY KEY constraints, in the order it
36
+ # makes them - all but the rowid's own primary key, even where two cover the same columns
37
+ def self.autoindexes(statement)
38
+ return [] if statement.select_statement
39
+
40
+ alias_column = new.__send__(:rowid_alias, statement)&.downcase(:ascii)
41
+ constraints = statement.columns.flat_map do |column|
42
+ (column.constraints || []).select { |c| UniqueColumnConstraint === c || (PrimaryKeyColumnConstraint === c && column.name.downcase(:ascii) != alias_column) }
43
+ end
44
+ constraints += (statement.constraints || []).select do |c|
45
+ UniqueTableConstraint === c || (PrimaryKeyTableConstraint === c && !(alias_column && c.columns.size == 1))
46
+ end
47
+ Array.new(constraints.size) { |i| "sqlite_autoindex_#{statement.table.name}_#{i + 1}" }
48
+ end
49
+
27
50
  # Takes a sqlite3-ruby `SQLite3::Database` (or anything with a compatible `#execute`)
28
51
  def self.from_sqlite(db) = new.load_sqlite(db)
29
52
 
30
53
  def initialize
31
54
  @tables = {}
32
55
  @views = {}
56
+ @indexes = {}
57
+ @triggers = {}
33
58
  end
34
59
 
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:)
60
+ def add_table(name, columns, schema: "main", hidden_columns: [], generated_columns: [], rowid_alias: nil, without_rowid: false, module_name: nil)
61
+ table = Table.new(schema:, name:, columns: columns&.freeze, hidden_columns: hidden_columns.freeze, generated_columns: generated_columns.freeze, rowid_alias:, without_rowid:, module_name:)
37
62
  @tables[key(schema, name)] = table
38
63
  end
39
64
 
@@ -43,83 +68,147 @@ module Plume
43
68
  @views[key(schema, name)] = View.new(schema:, name:, columns: columns&.freeze, select:)
44
69
  end
45
70
 
71
+ # `table` is the name of the table the index or trigger is on, in the same schema
72
+ def add_index(name, table, schema: "main") = @indexes[key(schema, name)] = Index.new(schema:, name:, table:)
73
+ def add_trigger(name, table, schema: "main") = @triggers[key(schema, name)] = Trigger.new(schema:, name:, table:)
74
+
46
75
  # Unqualified names are looked up the way SQLite does: temp first, then main,
47
76
  # then attached databases in the order they were added
48
77
  def table(name, schema: nil) = lookup(@tables, name, schema)
49
78
  def view(name, schema: nil) = lookup(@views, name, schema)
79
+ def index(name, schema: nil) = lookup(@indexes, name, schema)
80
+ def trigger(name, schema: nil) = lookup(@triggers, name, schema)
50
81
 
51
82
  def tables = @tables.values
52
83
  def views = @views.values
84
+ def indexes = @indexes.values
85
+ def triggers = @triggers.values
53
86
 
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
87
+ # Statements other than CREATE (inserts, pragmas…) do not change the schema, and are skipped
88
+ # without being parsed
56
89
  def load_sql(sql)
57
90
  Catalog.split_statements(sql).each do |statement_sql|
58
91
  leading = Plume.tokenize(statement_sql).first(3) - [:TEMP, :TEMPORARY]
59
- next unless leading[0] == :CREATE && (leading[1] in :TABLE | :VIEW)
92
+ next unless leading[0] == :CREATE && (leading[1] in :TABLE | :VIEW | :INDEX | :UNIQUE | :TRIGGER | :VIRTUAL)
60
93
 
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
94
+ apply(Parser.new(statement_sql).parse.first)
95
+ end
96
+ self
97
+ end
98
+
99
+ # Changes the catalog the way running `statement` would change the schema: CREATE adds,
100
+ # DROP removes, ALTER TABLE renames, adds and drops columns. Other statements change nothing
101
+ def apply(statement)
102
+ case statement
103
+ when CreateTableStatement then add_create_table(statement) unless statement.if_not_exists && table(statement.table.name, schema: statement.table.schema)
104
+ when CreateViewStatement then add_create_view(statement) unless statement.if_not_exists && view(statement.view.name, schema: statement.view.schema)
105
+ when CreateVirtualTableStatement
106
+ add_table(statement.table.name, nil, schema: statement.table.schema || "main", module_name: statement.module_name)
107
+ when CreateIndexStatement
108
+ schema = statement.index.schema || table(statement.table)&.schema || "main"
109
+ add_index(statement.index.name, statement.table, schema:)
110
+ when CreateTriggerStatement
111
+ schema = statement.trigger.schema || (statement.temporary ? "temp" : table(statement.table.table)&.schema || "main")
112
+ add_trigger(statement.trigger.name, statement.table.table, schema:)
113
+ when DropStatement then drop(statement.type, statement.target)
114
+ when AlterTableStatement then alter(statement)
65
115
  end
66
116
  self
67
117
  end
68
118
 
119
+ def initialize_copy(other)
120
+ super
121
+ @tables = @tables.dup
122
+ @views = @views.dup
123
+ @indexes = @indexes.dup
124
+ @triggers = @triggers.dup
125
+ end
126
+
69
127
  def load_sqlite(db)
70
128
  db.execute("PRAGMA database_list").each do |(_seq, schema, _file)|
71
129
  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)|
130
+ db.execute("SELECT type, name, tbl_name, sql FROM #{master} WHERE type IN ('table', 'view', 'index', 'trigger')").each do |(type, name, table, sql)|
73
131
  next if name.start_with?("sqlite_")
74
132
 
75
- if type == "view"
133
+ case type
134
+ when "view"
76
135
  statement = Parser.new(sql).parse.first
77
136
  add_view(name, statement.select, columns: statement.column_names, schema:)
78
- else
79
- add_sqlite_table(db, schema, name, sql)
137
+ when "index" then add_index(name, table, schema:)
138
+ when "trigger" then add_trigger(name, table, schema:)
139
+ else add_sqlite_table(db, schema, name, sql)
80
140
  end
81
141
  end
82
142
  end
83
143
  self
84
144
  end
85
145
 
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
146
+ # Splits SQL into statements, keeping trigger bodies (`BEGIN …; …; END`) in one piece
147
+ def self.split_statements(sql) = Plume.split(sql)
148
+
149
+ private
150
+
151
+ def drop(type, target)
152
+ collection = { TABLE: @tables, VIEW: @views, INDEX: @indexes, TRIGGER: @triggers }.fetch(type)
153
+ object = lookup(collection, target.table, target.schema) or return
154
+ collection.delete(key(object.schema, object.name))
155
+ return unless type == :TABLE || type == :VIEW
156
+
157
+ # A table takes its indexes and triggers with it, a view its triggers
158
+ [@indexes, @triggers].each do |dependents|
159
+ dependents.delete_if { |_, d| Catalog.same_name?(d.schema, object.schema) && Catalog.same_name?(d.table, object.name) }
109
160
  end
110
- statements << sql.byteslice(start, sql.bytesize - start)
111
- statements.reject { |s| Plume.tokenize(s).reject { |t| t == :SEMI }.empty? }
112
161
  end
113
162
 
114
- private
163
+ def alter(statement)
164
+ table = table(statement.table.table, schema: statement.table.schema) or return
165
+ columns = table.columns
166
+ rename = -> (list, from, to) { list&.map { |c| Catalog.same_name?(c, from) ? to : c } }
167
+
168
+ changed =
169
+ case statement.action
170
+ when :RENAME_TABLE then return rename_table(table, statement.new_name)
171
+ when :ADD_COLUMN
172
+ definition = statement.column_definition
173
+ generated = definition.constraints&.any?(GeneratedAsColumnConstraint)
174
+ table.with(columns: columns && [*columns, definition.name].freeze, generated_columns: generated ? [*table.generated_columns, definition.name].freeze : table.generated_columns)
175
+ when :RENAME_COLUMN
176
+ from, to = statement.column, statement.new_name
177
+ table.with(
178
+ columns: rename.(columns, from, to)&.freeze, generated_columns: rename.(table.generated_columns, from, to).freeze,
179
+ rowid_alias: Catalog.same_name?(table.rowid_alias, from) ? to : table.rowid_alias,
180
+ )
181
+ when :DROP_COLUMN
182
+ gone = -> (list) { list&.reject { |c| Catalog.same_name?(c, statement.column) }&.freeze }
183
+ table.with(columns: gone.(columns), generated_columns: gone.(table.generated_columns))
184
+ else return
185
+ end
186
+ @tables[key(table.schema, table.name)] = changed
187
+ end
188
+
189
+ def rename_table(table, new_name)
190
+ @tables.delete(key(table.schema, table.name))
191
+ @tables[key(table.schema, new_name)] = table.with(name: new_name)
192
+ old_prefix = "sqlite_autoindex_#{table.name}_"
193
+ @indexes.transform_values! { |i| (Catalog.same_name?(i.schema, table.schema) && Catalog.same_name?(i.table, table.name)) ? i.with(table: new_name) : i }
194
+ @indexes.keys.each do |k|
195
+ index = @indexes[k]
196
+ next unless index.name.start_with?(old_prefix) && index.table == new_name
197
+
198
+ @indexes.delete(k)
199
+ renamed = index.with(name: index.name.sub(old_prefix, "sqlite_autoindex_#{new_name}_"))
200
+ @indexes[key(renamed.schema, renamed.name)] = renamed
201
+ end
202
+ @triggers.transform_values! { |t| (Catalog.same_name?(t.schema, table.schema) && Catalog.same_name?(t.table, table.name)) ? t.with(table: new_name) : t }
203
+ end
115
204
 
116
- def key(schema, name) = [schema.to_s.downcase, name.to_s.downcase].freeze
205
+ def key(schema, name) = [schema.to_s.downcase(:ascii), name.to_s.downcase(:ascii)].freeze
117
206
 
118
207
  def lookup(collection, name, schema)
119
208
  return collection[key(schema, name)] if schema
120
209
 
121
210
  collection[key("temp", name)] || collection[key("main", name)] ||
122
- collection.find { |(_s, n), _| n == name.to_s.downcase }&.last
211
+ collection.find { |(_s, n), _| n == name.to_s.downcase(:ascii) }&.last
123
212
  end
124
213
 
125
214
  def schema_of(name, statement) = name.schema || (statement.temporary ? "temp" : "main")
@@ -135,7 +224,9 @@ module Plume
135
224
  end
136
225
 
137
226
  columns = statement.columns.map(&:name)
138
- add_table(name, columns, schema:, rowid_alias: rowid_alias(statement), without_rowid: statement.without_row_id)
227
+ generated = statement.columns.select { |c| c.constraints&.any?(GeneratedAsColumnConstraint) }.map(&:name)
228
+ add_table(name, columns, schema:, generated_columns: generated, rowid_alias: rowid_alias(statement), without_rowid: statement.without_row_id)
229
+ Catalog.autoindexes(statement).each { |index| add_index(index, name, schema:) }
139
230
  end
140
231
 
141
232
  def add_create_view(statement)
@@ -155,12 +246,12 @@ module Plume
155
246
  unless candidate
156
247
  table_pk = statement.constraints&.find { |c| PrimaryKeyTableConstraint === c }
157
248
  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 }
249
+ pk_name = table_pk.columns[0].name.downcase(:ascii)
250
+ candidate = statement.columns.find { |c| c.name.downcase(:ascii) == pk_name }
160
251
  end
161
252
  end
162
253
 
163
- candidate.name if candidate&.type&.text&.casecmp?("INTEGER")
254
+ candidate.name if Catalog.same_name?(candidate&.type&.text, "INTEGER")
164
255
  end
165
256
 
166
257
  def add_sqlite_table(db, schema, name, sql)
@@ -168,11 +259,16 @@ module Plume
168
259
  # hidden is 0 for ordinary columns, 1 for hidden virtual table columns, 2 and 3 for generated ones
169
260
  columns = info.reject { |row| row[6] == 1 }.map { |row| row[1] }
170
261
  hidden = info.select { |row| row[6] == 1 }.map { |row| row[1] }
262
+ generated = info.select { |row| row[6] == 2 || row[6] == 3 }.map { |row| row[1] }
171
263
  without_rowid = sql.to_s.match?(/\bWITHOUT\s+ROWID\s*;?\s*\z/i)
172
264
  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")
265
+ rowid_alias = pk_rows[0][1] if !without_rowid && pk_rows.size == 1 && Catalog.same_name?(pk_rows[0][2].to_s, "INTEGER")
174
266
 
175
- add_table(name, columns, schema:, hidden_columns: hidden, rowid_alias:, without_rowid:)
267
+ statement = Parser.new(sql).parse.first
268
+ module_name = statement.module_name if CreateVirtualTableStatement === statement
269
+ add_table(name, columns, schema:, hidden_columns: hidden, generated_columns: generated, rowid_alias:, without_rowid:, module_name:)
270
+ # The schema has no row for a WITHOUT ROWID table's primary key, though SQLite counts it as an index
271
+ Catalog.autoindexes(statement).each { |index| add_index(index, name, schema:) } if CreateTableStatement === statement
176
272
  end
177
273
 
178
274
  def quote(identifier) = %("#{identifier.to_s.gsub('"', '""')}")