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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +417 -0
- data/lib/athar/actor_lookup.rb +31 -0
- data/lib/athar/configuration.rb +35 -0
- data/lib/athar/context.rb +121 -0
- data/lib/athar/deletion.rb +61 -0
- data/lib/athar/engine.rb +11 -0
- data/lib/athar/metadata_stack.rb +37 -0
- data/lib/athar/retention.rb +131 -0
- data/lib/athar/retention_job.rb +11 -0
- data/lib/athar/sql.rb +67 -0
- data/lib/athar/table_event.rb +27 -0
- data/lib/athar/version.rb +5 -0
- data/lib/athar.rb +61 -0
- data/lib/generators/athar/fx_helper.rb +60 -0
- data/lib/generators/athar/install/functions/athar_capture_delete.sql +111 -0
- data/lib/generators/athar/install/functions/athar_capture_truncate.sql.erb +51 -0
- data/lib/generators/athar/install/functions/athar_filter_keys.sql +29 -0
- data/lib/generators/athar/install/install_generator.rb +116 -0
- data/lib/generators/athar/install/templates/install_migration.rb.erb +80 -0
- data/lib/generators/athar/install/templates/install_migration_fx.rb.erb +73 -0
- data/lib/generators/athar/model/model_generator.rb +344 -0
- data/lib/generators/athar/model/templates/migration.rb.erb +47 -0
- data/lib/generators/athar/model/templates/migration_fx.rb.erb +29 -0
- data/lib/generators/athar/model/triggers/athar_delete.sql.erb +14 -0
- data/lib/generators/athar/model/triggers/athar_truncate.sql.erb +8 -0
- metadata +144 -0
|
@@ -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
|