athar 0.1.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.
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require "athar/sql"
6
+ require_relative "../fx_helper"
7
+
8
+ module Athar
9
+ module Generators
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ include ::Rails::Generators::Migration
12
+ include FxHelper
13
+
14
+ source_root File.expand_path("templates", __dir__)
15
+
16
+ class_option :update,
17
+ type: :boolean,
18
+ default: false,
19
+ desc: "Generate a function-only migration that updates Athar SQL functions."
20
+
21
+ def validate_options!
22
+ ensure_raw_sql_supported! unless fx?
23
+ end
24
+
25
+ def write_function_files
26
+ return unless fx?
27
+
28
+ FileUtils.mkdir_p(functions_destination)
29
+ function_definitions.each do |function_definition|
30
+ path = File.join(functions_destination, "#{function_definition[:versioned_basename]}.sql")
31
+ next if File.exist?(path) && File.read(path) == function_definition[:body]
32
+
33
+ File.write(path, function_definition[:body])
34
+ end
35
+ end
36
+
37
+ def generate_migration
38
+ template = fx? ? "install_migration_fx.rb.erb" : "install_migration.rb.erb"
39
+ migration_template template, "db/migrate/#{migration_filename}.rb"
40
+ end
41
+
42
+ no_tasks do # rubocop:disable Metrics/BlockLength
43
+ def migration_filename
44
+ if options[:update]
45
+ version = function_definitions.map { |definition| definition[:version] }.max
46
+ "athar_update_functions_v#{version.to_s.rjust(2, "0")}"
47
+ else
48
+ "athar_install"
49
+ end
50
+ end
51
+
52
+ def function_definitions # rubocop:disable Metrics/MethodLength
53
+ @function_definitions ||= Athar::SQL::INSTALLED_FUNCTIONS.map do |name|
54
+ previous_version = previous_version_for(name)
55
+ new_body = Athar::SQL.read_function(name, foreign_key_type:)
56
+
57
+ version = if previous_version
58
+ previous_body = read_existing_function(name, previous_version)
59
+ previous_body == new_body ? previous_version : previous_version + 1
60
+ else
61
+ 1
62
+ end
63
+
64
+ {
65
+ name:,
66
+ version:,
67
+ previous_version:,
68
+ versioned_basename: "#{name}_v#{version.to_s.rjust(2, "0")}",
69
+ body: new_body,
70
+ signature: Athar::SQL.function_signature(name)
71
+ }
72
+ end
73
+ end
74
+
75
+ def previous_version_for(name)
76
+ return nil unless File.directory?(functions_destination)
77
+
78
+ Dir.entries(functions_destination)
79
+ .filter_map { |path| path[/\A#{Regexp.escape(name)}_v(\d+)\.sql\z/, 1]&.to_i }
80
+ .max
81
+ end
82
+
83
+ def functions_destination
84
+ File.expand_path("db/functions", destination_root)
85
+ end
86
+
87
+ def read_existing_function(name, version)
88
+ path = File.join(functions_destination, "#{name}_v#{version.to_s.rjust(2, "0")}.sql")
89
+ File.exist?(path) ? File.read(path) : nil
90
+ end
91
+
92
+ def function_drops
93
+ Athar::SQL::INSTALLED_FUNCTIONS.map do |name|
94
+ "DROP FUNCTION IF EXISTS #{name}(#{Athar::SQL.function_signature(name)}) CASCADE;"
95
+ end
96
+ end
97
+
98
+ def migration_class_name
99
+ migration_filename.camelize
100
+ end
101
+
102
+ def foreign_key_type
103
+ athar_foreign_key_type
104
+ end
105
+
106
+ def update?
107
+ options[:update]
108
+ end
109
+ end
110
+
111
+ def self.next_migration_number(dir)
112
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,80 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def up
3
+ <% unless update? -%>
4
+ primary_key_type, foreign_key_type = athar_primary_and_foreign_key_types
5
+
6
+ create_table :athar_deletions, id: primary_key_type do |t|
7
+ t.references :record,
8
+ polymorphic: true,
9
+ null: false,
10
+ type: foreign_key_type,
11
+ index: {name: "index_athar_deletions_on_record"}
12
+
13
+ t.references :actor,
14
+ polymorphic: true,
15
+ type: foreign_key_type,
16
+ index: {name: "index_athar_deletions_on_actor"}
17
+
18
+ t.string :schema_name
19
+ t.string :table_name, null: false
20
+ t.datetime :deleted_at, null: false
21
+ t.datetime :created_at, null: false
22
+
23
+ t.jsonb :record_data, null: false, default: {}
24
+ t.jsonb :metadata, null: false, default: {}
25
+ end
26
+
27
+ add_index :athar_deletions, [:deleted_at, :id]
28
+ add_index :athar_deletions, [:table_name, :deleted_at]
29
+ add_index :athar_deletions, [:schema_name, :table_name, :record_id],
30
+ name: "index_athar_deletions_on_record_lookup"
31
+
32
+ create_table :athar_table_events, id: primary_key_type do |t|
33
+ t.string :event_type, null: false
34
+ t.string :schema_name
35
+ t.string :table_name, null: false
36
+ t.references :actor,
37
+ polymorphic: true,
38
+ type: foreign_key_type,
39
+ index: {name: "index_athar_table_events_on_actor"}
40
+ t.jsonb :metadata, null: false, default: {}
41
+ t.datetime :occurred_at, null: false
42
+ t.datetime :created_at, null: false
43
+ end
44
+
45
+ add_index :athar_table_events, [:event_type, :table_name, :occurred_at],
46
+ name: "index_athar_table_events_on_type_table_time"
47
+ add_index :athar_table_events, :occurred_at
48
+ <% end -%>
49
+
50
+ execute(<<~SQL)
51
+ <% function_definitions.each do |function_definition| -%>
52
+ <%= indent_sql(function_definition[:body], 6) %>
53
+ <% end -%>
54
+ SQL
55
+ end
56
+
57
+ def down
58
+ <% unless update? -%>
59
+ drop_table :athar_table_events if table_exists?(:athar_table_events)
60
+ drop_table :athar_deletions if table_exists?(:athar_deletions)
61
+ <% end -%>
62
+
63
+ execute(<<~SQL)
64
+ <% function_drops.each do |drop_sql| -%>
65
+ <%= drop_sql %>
66
+ <% end -%>
67
+ SQL
68
+ end
69
+
70
+ private
71
+
72
+ def athar_primary_and_foreign_key_types
73
+ generators_config = Rails.configuration.generators
74
+ orm = generators_config.orm
75
+ setting = generators_config.options[orm][:primary_key_type]
76
+ primary_key_type = setting || :primary_key
77
+ foreign_key_type = setting || :bigint
78
+ [primary_key_type, foreign_key_type]
79
+ end
80
+ end
@@ -0,0 +1,73 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ <% unless update? -%>
4
+ primary_key_type, foreign_key_type = athar_primary_and_foreign_key_types
5
+
6
+ create_table :athar_deletions, id: primary_key_type do |t|
7
+ t.references :record,
8
+ polymorphic: true,
9
+ null: false,
10
+ type: foreign_key_type,
11
+ index: {name: "index_athar_deletions_on_record"}
12
+
13
+ t.references :actor,
14
+ polymorphic: true,
15
+ type: foreign_key_type,
16
+ index: {name: "index_athar_deletions_on_actor"}
17
+
18
+ t.string :schema_name
19
+ t.string :table_name, null: false
20
+ t.datetime :deleted_at, null: false
21
+ t.datetime :created_at, null: false
22
+
23
+ t.jsonb :record_data, null: false, default: {}
24
+ t.jsonb :metadata, null: false, default: {}
25
+ end
26
+
27
+ add_index :athar_deletions, [:deleted_at, :id]
28
+ add_index :athar_deletions, [:table_name, :deleted_at]
29
+ add_index :athar_deletions, [:schema_name, :table_name, :record_id],
30
+ name: "index_athar_deletions_on_record_lookup"
31
+
32
+ create_table :athar_table_events, id: primary_key_type do |t|
33
+ t.string :event_type, null: false
34
+ t.string :schema_name
35
+ t.string :table_name, null: false
36
+ t.references :actor,
37
+ polymorphic: true,
38
+ type: foreign_key_type,
39
+ index: {name: "index_athar_table_events_on_actor"}
40
+ t.jsonb :metadata, null: false, default: {}
41
+ t.datetime :occurred_at, null: false
42
+ t.datetime :created_at, null: false
43
+ end
44
+
45
+ add_index :athar_table_events, [:event_type, :table_name, :occurred_at],
46
+ name: "index_athar_table_events_on_type_table_time"
47
+ add_index :athar_table_events, :occurred_at
48
+ <% end -%>
49
+
50
+ <% function_definitions.each do |function_definition| -%>
51
+ <% if function_definition[:previous_version].nil? -%>
52
+ create_function :<%= function_definition[:name] %>, version: <%= function_definition[:version] %>
53
+ <% elsif function_definition[:previous_version] == function_definition[:version] -%>
54
+ # <%= function_definition[:name] %> is already at version <%= function_definition[:version] %>; nothing to do.
55
+ <% else -%>
56
+ update_function :<%= function_definition[:name] %>,
57
+ version: <%= function_definition[:version] %>,
58
+ revert_to_version: <%= function_definition[:previous_version] %>
59
+ <% end -%>
60
+ <% end -%>
61
+ end
62
+
63
+ private
64
+
65
+ def athar_primary_and_foreign_key_types
66
+ generators_config = Rails.configuration.generators
67
+ orm = generators_config.orm
68
+ setting = generators_config.options[orm][:primary_key_type]
69
+ primary_key_type = setting || :primary_key
70
+ foreign_key_type = setting || :bigint
71
+ [primary_key_type, foreign_key_type]
72
+ end
73
+ end
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require "athar/sql"
6
+ require_relative "../fx_helper"
7
+
8
+ module Athar
9
+ module Generators
10
+ class ModelGenerator < ::Rails::Generators::NamedBase # rubocop:disable Metrics/ClassLength
11
+ include ::Rails::Generators::Migration
12
+ include FxHelper
13
+
14
+ ALLOWED_ID_TYPES = %w[bigint integer uuid].freeze
15
+ CAPTURE_MODES = %w[identity only snapshot].freeze
16
+ UNSAFE_COLUMN_REGEX = /[\s,{}"\\']/
17
+ # PostgreSQL unquoted identifier surface: starts with letter or _,
18
+ # then letters/digits/underscores. Matches what the generator embeds
19
+ # inside both `"identifier"` and `'string'` SQL contexts.
20
+ SAFE_IDENTIFIER_REGEX = /\A[A-Za-z_][A-Za-z0-9_]*\z/
21
+ # Ruby class names, with optional `::` namespacing.
22
+ SAFE_CLASS_NAME_REGEX = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/
23
+
24
+ source_root File.expand_path("templates", __dir__)
25
+
26
+ argument :name, type: :string, banner: "ModelName"
27
+
28
+ class_option :only, type: :array, default: nil, desc: "Capture only the listed columns. Comma-separated."
29
+ class_option :snapshot, type: :boolean, default: false, desc: "Capture all row attributes."
30
+ class_option :primary_key, type: :string, default: nil, desc: "Primary key column."
31
+ class_option :record_type, type: :string, default: nil, desc: "Override the stored record_type."
32
+ class_option :record_type_column, type: :string, default: nil, desc: "STI column. Pass 'false' to disable."
33
+ class_option :schema, type: :string, default: nil, desc: "PostgreSQL schema."
34
+ class_option :track_truncate, type: :boolean, default: false, desc: "Install AFTER TRUNCATE trigger."
35
+ class_option :update, type: :boolean, default: false, desc: "Generate an update migration."
36
+ class_option :remove, type: :boolean, default: false, desc: "Generate a removal migration."
37
+
38
+ def validate_options!
39
+ validate_capture_mode!
40
+ validate_identifiers!
41
+ validate_id_type!
42
+ validate_columns!
43
+ ensure_raw_sql_supported! unless fx?
44
+ end
45
+
46
+ def write_trigger_files # rubocop:disable Metrics/AbcSize
47
+ return unless fx?
48
+ return if remove?
49
+
50
+ FileUtils.mkdir_p(triggers_destination)
51
+ # Reading trigger_descriptors first caches the version computation
52
+ # against the on-disk state *before* we write the new files.
53
+ trigger_descriptors.each do |descriptor|
54
+ path = File.join(
55
+ triggers_destination,
56
+ "#{descriptor[:name]}_v#{descriptor[:version].to_s.rjust(2, "0")}.sql"
57
+ )
58
+ next if File.exist?(path) && File.read(path) == descriptor[:body]
59
+
60
+ File.write(path, descriptor[:body])
61
+ end
62
+ end
63
+
64
+ def generate_migration
65
+ template = fx? ? "migration_fx.rb.erb" : "migration.rb.erb"
66
+ migration_template template, "db/migrate/#{migration_filename}.rb"
67
+ end
68
+
69
+ no_tasks do # rubocop:disable Metrics/BlockLength
70
+ def trigger_descriptors
71
+ @trigger_descriptors ||= begin
72
+ descriptors = []
73
+ unless remove?
74
+ descriptors << build_descriptor(trigger_name, render_trigger("athar_delete"))
75
+ if track_truncate?
76
+ descriptors << build_descriptor(truncate_trigger_name, render_trigger("athar_truncate"))
77
+ end
78
+ end
79
+ descriptors
80
+ end
81
+ end
82
+
83
+ def build_descriptor(name, body)
84
+ version = trigger_version_for(name, body)
85
+ previous = previous_version_for(name)
86
+ {
87
+ name:,
88
+ body:,
89
+ version:,
90
+ previous_version: previous,
91
+ unchanged: !previous.nil? && version == previous
92
+ }
93
+ end
94
+
95
+ def trigger_name
96
+ "athar_on_#{table_name}"
97
+ end
98
+
99
+ def truncate_trigger_name
100
+ "athar_truncate_on_#{table_name}"
101
+ end
102
+
103
+ def render_trigger(template_name)
104
+ path = File.join(Athar::SQL::MODEL_TRIGGERS_DIR, "#{template_name}.sql.erb")
105
+ template = File.read(path)
106
+ locals = {
107
+ schema_name:,
108
+ table_name:,
109
+ trigger_name:,
110
+ truncate_trigger_name:,
111
+ record_type:,
112
+ primary_key:,
113
+ id_type:,
114
+ record_type_column_arg:,
115
+ capture_mode:,
116
+ columns_arg:
117
+ }
118
+ Athar::SQL.render(template, locals)
119
+ end
120
+
121
+ def trigger_sql
122
+ render_trigger("athar_delete")
123
+ end
124
+
125
+ def truncate_trigger_sql
126
+ render_trigger("athar_truncate")
127
+ end
128
+
129
+ def drop_trigger_sql
130
+ [
131
+ %(DROP TRIGGER IF EXISTS "#{trigger_name}" ON "#{schema_name}"."#{table_name}";),
132
+ (track_truncate? ? %(DROP TRIGGER IF EXISTS "#{truncate_trigger_name}" ON "#{schema_name}"."#{table_name}";) : nil) # rubocop:disable Layout/LineLength
133
+ ].compact.join("\n")
134
+ end
135
+
136
+ def triggers_destination
137
+ File.expand_path("db/triggers", destination_root)
138
+ end
139
+
140
+ def trigger_version_for(target, body)
141
+ previous = previous_version_for(target)
142
+ return 1 if previous.nil?
143
+
144
+ previous_path = File.join(triggers_destination, "#{target}_v#{previous.to_s.rjust(2, "0")}.sql")
145
+ previous_body = File.exist?(previous_path) ? File.read(previous_path) : nil
146
+ previous_body == body ? previous : previous + 1
147
+ end
148
+
149
+ def previous_version_for(target)
150
+ return nil unless File.directory?(triggers_destination)
151
+
152
+ Dir.entries(triggers_destination)
153
+ .filter_map { |path| path[/\A#{Regexp.escape(target)}_v(\d+)\.sql\z/, 1]&.to_i }
154
+ .max
155
+ end
156
+
157
+ def model_class
158
+ @model_class ||= name.classify.constantize
159
+ end
160
+
161
+ def schema_name
162
+ options[:schema] || schema_and_table_name.first || "public"
163
+ end
164
+
165
+ def table_name
166
+ schema_and_table_name.last
167
+ end
168
+
169
+ def schema_and_table_name
170
+ full = model_class.table_name.to_s
171
+ @schema_and_table_name ||= full.include?(".") ? full.split(".", 2) : [nil, full]
172
+ end
173
+
174
+ def record_type
175
+ options[:record_type] || model_class.base_class.name
176
+ end
177
+
178
+ def primary_key
179
+ options[:primary_key] || model_class.primary_key.to_s
180
+ end
181
+
182
+ def id_type
183
+ column = model_class.columns_hash[primary_key]
184
+ raise_invalid("Primary key column #{primary_key.inspect} not found on #{table_name}") unless column
185
+
186
+ sql_type = column.sql_type.to_s.downcase
187
+ case sql_type
188
+ when "bigint", "int8" then "bigint"
189
+ when "integer", "int", "int4" then "integer"
190
+ when "uuid" then "uuid"
191
+ else
192
+ raise_invalid("Unsupported primary key SQL type #{sql_type.inspect}; allowed: #{ALLOWED_ID_TYPES.inspect}")
193
+ end
194
+ end
195
+
196
+ def record_type_column # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
197
+ override = options[:record_type_column]
198
+ if override.nil?
199
+ inheritance = model_class.inheritance_column
200
+ inheritance if model_class.columns_hash.key?(inheritance.to_s)
201
+ elsif override.to_s == "false"
202
+ nil
203
+ else
204
+ unless model_class.columns_hash.key?(override.to_s)
205
+ raise_invalid("Record type column #{override.inspect} not found on #{table_name}")
206
+ end
207
+ override.to_s
208
+ end
209
+ end
210
+
211
+ def record_type_column_arg
212
+ rtc = record_type_column
213
+ rtc ? "'#{rtc}'" : "'null'"
214
+ end
215
+
216
+ def capture_mode
217
+ if options[:snapshot]
218
+ "snapshot"
219
+ elsif options[:only]
220
+ "only"
221
+ else
222
+ "identity"
223
+ end
224
+ end
225
+
226
+ def columns
227
+ Array(options[:only]).flat_map { |item| item.to_s.split(",") }.map(&:strip).reject(&:empty?)
228
+ end
229
+
230
+ def columns_arg
231
+ capture_mode == "only" ? "'{#{columns.join(",")}}'" : "'null'"
232
+ end
233
+
234
+ def migration_filename
235
+ if remove?
236
+ "athar_remove_#{table_name}_trigger"
237
+ elsif update?
238
+ # Fold the new version into the filename so consecutive --update
239
+ # runs produce distinct files and Ruby constants.
240
+ version = trigger_descriptors.map { |descriptor| descriptor[:version] }.max
241
+ "athar_update_#{table_name}_trigger_v#{version.to_s.rjust(2, "0")}"
242
+ else
243
+ "athar_install_#{table_name}_trigger"
244
+ end
245
+ end
246
+
247
+ def migration_class_name
248
+ migration_filename.camelize
249
+ end
250
+
251
+ # The `on:` argument passed to Fx's create_trigger / update_trigger /
252
+ # drop_trigger. For the public schema we keep the bare symbol so the
253
+ # generated migration matches the Rails convention. For non-public
254
+ # schemas we pass a "schema.table" string so DROP TRIGGER … ON … hits
255
+ # the correct relation regardless of search_path.
256
+ def fx_on_argument
257
+ if schema_name == "public"
258
+ ":#{table_name}"
259
+ else
260
+ %("#{schema_name}.#{table_name}")
261
+ end
262
+ end
263
+
264
+ def track_truncate?
265
+ options[:track_truncate]
266
+ end
267
+
268
+ def update?
269
+ options[:update]
270
+ end
271
+
272
+ def remove?
273
+ options[:remove]
274
+ end
275
+ end
276
+
277
+ def self.next_migration_number(dir)
278
+ ::ActiveRecord::Generators::Base.next_migration_number(dir)
279
+ end
280
+
281
+ private
282
+
283
+ def validate_capture_mode!
284
+ return unless options[:only] && options[:snapshot]
285
+
286
+ raise_invalid("--only and --snapshot are mutually exclusive")
287
+ end
288
+
289
+ def validate_identifiers!
290
+ validate_safe_identifier!("schema", schema_name)
291
+ return if remove?
292
+
293
+ validate_safe_identifier!("table", table_name)
294
+ validate_safe_identifier!("primary_key", primary_key)
295
+ validate_safe_class_name!("record_type", record_type)
296
+
297
+ rtc_override = options[:record_type_column]
298
+ return if rtc_override.nil? || rtc_override.to_s == "false"
299
+
300
+ # Validate shape before record_type_column tries to look it up against
301
+ # the model's columns; otherwise an unsafe value would surface as a
302
+ # confusing "not found" error.
303
+ validate_safe_identifier!("record_type_column", rtc_override)
304
+ end
305
+
306
+ def validate_safe_identifier!(label, value)
307
+ return if value.to_s.match?(SAFE_IDENTIFIER_REGEX)
308
+
309
+ raise_invalid("#{label} #{value.inspect} is not a safe SQL identifier; allowed: #{SAFE_IDENTIFIER_REGEX.source}") # rubocop:disable Layout/LineLength
310
+ end
311
+
312
+ def validate_safe_class_name!(label, value)
313
+ return if value.to_s.match?(SAFE_CLASS_NAME_REGEX)
314
+
315
+ raise_invalid("#{label} #{value.inspect} is not a safe Ruby class name")
316
+ end
317
+
318
+ def validate_id_type!
319
+ return if remove?
320
+ return if ALLOWED_ID_TYPES.include?(id_type)
321
+
322
+ raise_invalid("id type must be one of #{ALLOWED_ID_TYPES.inspect}, got #{id_type.inspect}")
323
+ end
324
+
325
+ def validate_columns!
326
+ return unless options[:only]
327
+
328
+ columns.each do |column|
329
+ if column.match?(UNSAFE_COLUMN_REGEX)
330
+ raise_invalid("column name #{column.inspect} contains unsafe characters")
331
+ end
332
+
333
+ unless model_class.columns_hash.key?(column)
334
+ raise_invalid("column #{column.inspect} not found on #{table_name}")
335
+ end
336
+ end
337
+ end
338
+
339
+ def raise_invalid(message)
340
+ raise ::Thor::Error, "Athar generator error: #{message}"
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,47 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ <% if remove? -%>
3
+ def up
4
+ execute(<<~SQL)
5
+ <%= indent_sql(drop_trigger_sql, 6) %>
6
+ SQL
7
+ end
8
+
9
+ def down
10
+ raise ActiveRecord::IrreversibleMigration
11
+ end
12
+ <% elsif update? -%>
13
+ def up
14
+ execute(<<~SQL)
15
+ <%= indent_sql(drop_trigger_sql, 6) %>
16
+
17
+ <%= indent_sql(trigger_sql, 6) %>
18
+ <% if track_truncate? -%>
19
+
20
+ <%= indent_sql(truncate_trigger_sql, 6) %>
21
+ <% end -%>
22
+ SQL
23
+ end
24
+
25
+ def down
26
+ raise ActiveRecord::IrreversibleMigration
27
+ end
28
+ <% else -%>
29
+ def up
30
+ execute(<<~SQL)
31
+ <%= indent_sql(drop_trigger_sql, 6) %>
32
+
33
+ <%= indent_sql(trigger_sql, 6) %>
34
+ <% if track_truncate? -%>
35
+
36
+ <%= indent_sql(truncate_trigger_sql, 6) %>
37
+ <% end -%>
38
+ SQL
39
+ end
40
+
41
+ def down
42
+ execute(<<~SQL)
43
+ <%= indent_sql(drop_trigger_sql, 6) %>
44
+ SQL
45
+ end
46
+ <% end -%>
47
+ end
@@ -0,0 +1,29 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ <% if remove? -%>
3
+ def up
4
+ drop_trigger :<%= trigger_name %>, on: <%= fx_on_argument %>
5
+ <% if track_truncate? -%>
6
+ drop_trigger :<%= truncate_trigger_name %>, on: <%= fx_on_argument %>
7
+ <% end -%>
8
+ end
9
+
10
+ def down
11
+ raise ActiveRecord::IrreversibleMigration
12
+ end
13
+ <% else -%>
14
+ def change
15
+ <% trigger_descriptors.each do |t| -%>
16
+ <% if t[:previous_version].nil? -%>
17
+ create_trigger :<%= t[:name] %>, on: <%= fx_on_argument %>, version: <%= t[:version] %>
18
+ <% elsif t[:unchanged] -%>
19
+ # <%= t[:name] %> is already at version <%= t[:version] %>; nothing to do.
20
+ <% else -%>
21
+ update_trigger :<%= t[:name] %>,
22
+ on: <%= fx_on_argument %>,
23
+ version: <%= t[:version] %>,
24
+ revert_to_version: <%= t[:previous_version] %>
25
+ <% end -%>
26
+ <% end -%>
27
+ end
28
+ <% end -%>
29
+ end