declare_schema 0.3.0.pre.1 → 0.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -4
- data/Gemfile.lock +14 -14
- data/README.md +20 -0
- data/lib/declare_schema.rb +2 -1
- data/lib/declare_schema/model.rb +62 -24
- data/lib/declare_schema/model/foreign_key_definition.rb +73 -0
- data/lib/declare_schema/model/index_definition.rb +138 -0
- data/lib/declare_schema/version.rb +1 -1
- data/lib/generators/declare_schema/migration/migrator.rb +26 -21
- data/lib/generators/declare_schema/migration/templates/migration.rb.erb +1 -1
- data/spec/lib/declare_schema/api_spec.rb +7 -8
- data/spec/lib/declare_schema/generator_spec.rb +51 -10
- data/spec/lib/declare_schema/interactive_primary_key_spec.rb +16 -14
- data/spec/lib/declare_schema/migration_generator_spec.rb +594 -233
- data/spec/lib/declare_schema/model/index_definition_spec.rb +123 -0
- data/spec/lib/generators/declare_schema/migration/migrator_spec.rb +30 -0
- data/spec/spec_helper.rb +4 -0
- data/spec/support/acceptance_spec_helpers.rb +57 -0
- metadata +6 -3
- data/lib/declare_schema/model/index_spec.rb +0 -175
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../../../lib/declare_schema/model/index_definition'
|
4
|
+
|
5
|
+
# Beware: It looks out that Rails' sqlite3 driver has a bug in retrieving indexes.
|
6
|
+
# In sqlite3/schema_statements, it skips over any index that starts with sqlite_:
|
7
|
+
# next if row["name"].starts_with?("sqlite_")
|
8
|
+
# but this will skip over any indexes created to support "unique" column constraints.
|
9
|
+
# This gem provides an explicit name for all indexes so it shouldn't be affected by the bug...
|
10
|
+
# unless you manually create any Sqlite tables with UNIQUE constraints.
|
11
|
+
|
12
|
+
RSpec.describe DeclareSchema::Model::IndexDefinition do
|
13
|
+
before do
|
14
|
+
load File.expand_path('../prepare_testapp.rb', __dir__)
|
15
|
+
|
16
|
+
class IndexDefinitionTestModel < ActiveRecord::Base
|
17
|
+
fields do
|
18
|
+
name :string, limit: 127, index: true
|
19
|
+
|
20
|
+
timestamps
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class IndexDefinitionCompoundIndexModel < ActiveRecord::Base
|
25
|
+
fields do
|
26
|
+
fk1_id :integer
|
27
|
+
fk2_id :integer
|
28
|
+
|
29
|
+
timestamps
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
let(:model_class) { IndexDefinitionTestModel }
|
35
|
+
|
36
|
+
describe 'instance methods' do
|
37
|
+
let(:model) { model_class.new }
|
38
|
+
subject { declared_class.new(model_class) }
|
39
|
+
|
40
|
+
it 'has index_definitions' do
|
41
|
+
expect(model_class.index_definitions).to be_kind_of(Array)
|
42
|
+
expect(model_class.index_definitions.map(&:name)).to eq(['on_name'])
|
43
|
+
expect([:name, :fields, :unique].map { |attr| model_class.index_definitions[0].send(attr)}).to eq(
|
44
|
+
['on_name', ['name'], false]
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'has index_definitions_with_primary_key' do
|
49
|
+
expect(model_class.index_definitions_with_primary_key).to be_kind_of(Array)
|
50
|
+
result = model_class.index_definitions_with_primary_key.sort_by(&:name)
|
51
|
+
expect(result.map(&:name)).to eq(['PRIMARY', 'on_name'])
|
52
|
+
expect([:name, :fields, :unique].map { |attr| result[0].send(attr)}).to eq(
|
53
|
+
['PRIMARY', ['id'], true]
|
54
|
+
)
|
55
|
+
expect([:name, :fields, :unique].map { |attr| result[1].send(attr)}).to eq(
|
56
|
+
['on_name', ['name'], false]
|
57
|
+
)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
describe 'class << self' do
|
62
|
+
context 'with a migrated database' do
|
63
|
+
before do
|
64
|
+
ActiveRecord::Base.connection.execute <<~EOS
|
65
|
+
CREATE TABLE index_definition_test_models (
|
66
|
+
id INTEGER NOT NULL PRIMARY KEY,
|
67
|
+
name TEXT NOT NULL
|
68
|
+
)
|
69
|
+
EOS
|
70
|
+
ActiveRecord::Base.connection.execute <<~EOS
|
71
|
+
CREATE UNIQUE INDEX index_definition_test_models_on_name ON index_definition_test_models(name)
|
72
|
+
EOS
|
73
|
+
ActiveRecord::Base.connection.execute <<~EOS
|
74
|
+
CREATE TABLE index_definition_compound_index_models (
|
75
|
+
fk1_id INTEGER NULL,
|
76
|
+
fk2_id INTEGER NULL,
|
77
|
+
PRIMARY KEY (fk1_id, fk2_id)
|
78
|
+
)
|
79
|
+
EOS
|
80
|
+
ActiveRecord::Base.connection.schema_cache.clear!
|
81
|
+
end
|
82
|
+
|
83
|
+
describe 'for_model' do
|
84
|
+
subject { described_class.for_model(model_class) }
|
85
|
+
|
86
|
+
context 'with single-column PK' do
|
87
|
+
it 'returns the indexes for the model' do
|
88
|
+
expect(subject.size).to eq(2), subject.inspect
|
89
|
+
expect([:name, :columns, :unique].map { |attr| subject[0].send(attr) }).to eq(
|
90
|
+
['index_definition_test_models_on_name', ['name'], true]
|
91
|
+
)
|
92
|
+
expect([:name, :columns, :unique].map { |attr| subject[1].send(attr) }).to eq(
|
93
|
+
['PRIMARY', ['id'], true]
|
94
|
+
)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
context 'with compound-column PK' do
|
99
|
+
let(:model_class) { IndexDefinitionCompoundIndexModel }
|
100
|
+
|
101
|
+
it 'returns the indexes for the model' do
|
102
|
+
# Simulate MySQL for Rails 4 work-around
|
103
|
+
if Rails::VERSION::MAJOR < 5
|
104
|
+
expect(model_class.connection).to receive(:primary_key).with('index_definition_compound_index_models').and_return(nil)
|
105
|
+
connection_stub = instance_double(ActiveRecord::ConnectionAdapters::SQLite3Adapter, "connection")
|
106
|
+
expect(connection_stub).to receive(:indexes).
|
107
|
+
with('index_definition_compound_index_models').
|
108
|
+
and_return([DeclareSchema::Model::IndexDefinition.new(model_class, ['fk1_id', 'fk2_id'], name: 'PRIMARY')])
|
109
|
+
|
110
|
+
expect(model_class.connection).to receive(:dup).and_return(connection_stub)
|
111
|
+
end
|
112
|
+
|
113
|
+
expect(subject.size).to eq(1), subject.inspect
|
114
|
+
expect([:name, :columns, :unique].map { |attr| subject[0].send(attr) }).to eq(
|
115
|
+
['PRIMARY', ['fk1_id', 'fk2_id'], true]
|
116
|
+
)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
# TODO: fill out remaining tests
|
123
|
+
end
|
@@ -36,6 +36,36 @@ module Generators
|
|
36
36
|
end
|
37
37
|
end
|
38
38
|
end
|
39
|
+
|
40
|
+
describe '#before_generating_migration' do
|
41
|
+
it 'requires a block be passed' do
|
42
|
+
expect { described_class.before_generating_migration }.to raise_error(ArgumentError, 'A block is required when setting the before_generating_migration callback')
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe 'load_rails_models' do
|
47
|
+
before do
|
48
|
+
expect(Rails.application).to receive(:eager_load!)
|
49
|
+
expect(Rails::Engine).to receive(:subclasses).and_return([])
|
50
|
+
end
|
51
|
+
|
52
|
+
subject { described_class.new.load_rails_models }
|
53
|
+
|
54
|
+
context 'when a before_generating_migration callback is configured' do
|
55
|
+
let(:dummy_proc) { -> {} }
|
56
|
+
|
57
|
+
before do
|
58
|
+
described_class.before_generating_migration(&dummy_proc)
|
59
|
+
expect(dummy_proc).to receive(:call).and_return(true)
|
60
|
+
end
|
61
|
+
|
62
|
+
it { should be_truthy }
|
63
|
+
end
|
64
|
+
|
65
|
+
context 'when no before_generating_migration callback is configured' do
|
66
|
+
it { should be_nil }
|
67
|
+
end
|
68
|
+
end
|
39
69
|
end
|
40
70
|
end
|
41
71
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -4,7 +4,11 @@ require "bundler/setup"
|
|
4
4
|
require "declare_schema"
|
5
5
|
require "climate_control"
|
6
6
|
|
7
|
+
require_relative "./support/acceptance_spec_helpers"
|
8
|
+
|
7
9
|
RSpec.configure do |config|
|
10
|
+
config.include AcceptanceSpecHelpers
|
11
|
+
|
8
12
|
# Enable flags like --only-failures and --next-failure
|
9
13
|
config.example_status_persistence_file_path = ".rspec_status"
|
10
14
|
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AcceptanceSpecHelpers
|
4
|
+
def generate_model(model_name, *fields)
|
5
|
+
Rails::Generators.invoke('declare_schema:model', [model_name, *fields])
|
6
|
+
end
|
7
|
+
|
8
|
+
def generate_migrations(*flags)
|
9
|
+
Rails::Generators.invoke('declare_schema:migration', flags)
|
10
|
+
end
|
11
|
+
|
12
|
+
def expect_model_definition_to_eq(model, expectation)
|
13
|
+
expect_file_to_eq("#{TESTAPP_PATH}/app/models/#{model}.rb", expectation)
|
14
|
+
end
|
15
|
+
|
16
|
+
def expect_test_definition_to_eq(model, expectation)
|
17
|
+
expect_file_to_eq("#{TESTAPP_PATH}/test/models/#{model}_test.rb", expectation)
|
18
|
+
end
|
19
|
+
|
20
|
+
def expect_test_fixture_to_eq(model, expectation)
|
21
|
+
expect_file_to_eq("#{TESTAPP_PATH}/test/fixtures/#{model}.yml", expectation)
|
22
|
+
end
|
23
|
+
|
24
|
+
def expect_file_to_eq(file_path, expectation)
|
25
|
+
expect(File.exist?(file_path)).to be_truthy
|
26
|
+
expect(File.read(file_path)).to eq(expectation)
|
27
|
+
end
|
28
|
+
|
29
|
+
def clean_up_model(model)
|
30
|
+
system("rm -rf #{TESTAPP_PATH}/app/models/#{model}.rb #{TESTAPP_PATH}/test/models/#{model}.rb #{TESTAPP_PATH}/test/fixtures/#{model}.rb")
|
31
|
+
end
|
32
|
+
|
33
|
+
def load_models
|
34
|
+
Rails.application.config.autoload_paths += ["#{TESTAPP_PATH}/app/models"]
|
35
|
+
$LOAD_PATH << "#{TESTAPP_PATH}/app/models"
|
36
|
+
end
|
37
|
+
|
38
|
+
def migrate_up(expected_value)
|
39
|
+
MigrationUpEquals.new(expected_value)
|
40
|
+
end
|
41
|
+
|
42
|
+
def migrate_down(expected_value)
|
43
|
+
MigrationDownEquals.new(expected_value)
|
44
|
+
end
|
45
|
+
|
46
|
+
class MigrationUpEquals < RSpec::Matchers::BuiltIn::Eq
|
47
|
+
def matches?(subject)
|
48
|
+
super(subject[0])
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class MigrationDownEquals < RSpec::Matchers::BuiltIn::Eq
|
53
|
+
def matches?(subject)
|
54
|
+
super(subject[1])
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: declare_schema
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Invoca Development adapted from hobo_fields by Tom Locke
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-12-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -59,7 +59,8 @@ files:
|
|
59
59
|
- lib/declare_schema/field_declaration_dsl.rb
|
60
60
|
- lib/declare_schema/model.rb
|
61
61
|
- lib/declare_schema/model/field_spec.rb
|
62
|
-
- lib/declare_schema/model/
|
62
|
+
- lib/declare_schema/model/foreign_key_definition.rb
|
63
|
+
- lib/declare_schema/model/index_definition.rb
|
63
64
|
- lib/declare_schema/railtie.rb
|
64
65
|
- lib/declare_schema/version.rb
|
65
66
|
- lib/generators/declare_schema/migration/USAGE
|
@@ -76,9 +77,11 @@ files:
|
|
76
77
|
- spec/lib/declare_schema/generator_spec.rb
|
77
78
|
- spec/lib/declare_schema/interactive_primary_key_spec.rb
|
78
79
|
- spec/lib/declare_schema/migration_generator_spec.rb
|
80
|
+
- spec/lib/declare_schema/model/index_definition_spec.rb
|
79
81
|
- spec/lib/declare_schema/prepare_testapp.rb
|
80
82
|
- spec/lib/generators/declare_schema/migration/migrator_spec.rb
|
81
83
|
- spec/spec_helper.rb
|
84
|
+
- spec/support/acceptance_spec_helpers.rb
|
82
85
|
- test_responses.txt
|
83
86
|
homepage: https://github.com/Invoca/declare_schema
|
84
87
|
licenses: []
|
@@ -1,175 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module DeclareSchema
|
4
|
-
module Model
|
5
|
-
class IndexSpec
|
6
|
-
include Comparable
|
7
|
-
|
8
|
-
attr_reader :table, :fields, :explicit_name, :name, :unique, :where
|
9
|
-
|
10
|
-
class IndexNameTooLongError < RuntimeError; end
|
11
|
-
|
12
|
-
PRIMARY_KEY_NAME = "PRIMARY_KEY"
|
13
|
-
MYSQL_INDEX_NAME_MAX_LENGTH = 64
|
14
|
-
|
15
|
-
def initialize(model, fields, options = {})
|
16
|
-
@model = model
|
17
|
-
@table = options.delete(:table_name) || model.table_name
|
18
|
-
@fields = Array.wrap(fields).map(&:to_s)
|
19
|
-
@explicit_name = options[:name] unless options.delete(:allow_equivalent)
|
20
|
-
@name = options.delete(:name) || model.connection.index_name(table, column: @fields).gsub(/index.*_on_/, 'on_')
|
21
|
-
@unique = options.delete(:unique) || name == PRIMARY_KEY_NAME || false
|
22
|
-
|
23
|
-
if @name.length > MYSQL_INDEX_NAME_MAX_LENGTH
|
24
|
-
raise IndexNameTooLongError, "Index '#{@name}' exceeds MySQL limit of #{MYSQL_INDEX_NAME_MAX_LENGTH} characters. Give it a shorter name."
|
25
|
-
end
|
26
|
-
|
27
|
-
if (where = options[:where])
|
28
|
-
@where = where.start_with?('(') ? where : "(#{where})"
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
class << self
|
33
|
-
# extract IndexSpecs from an existing table
|
34
|
-
def for_model(model, old_table_name = nil)
|
35
|
-
t = old_table_name || model.table_name
|
36
|
-
connection = model.connection.dup
|
37
|
-
# TODO: Change below to use prepend
|
38
|
-
class << connection # defeat Rails code that skips the primary keys by changing their name to PRIMARY_KEY_NAME
|
39
|
-
def each_hash(result)
|
40
|
-
super do |hash|
|
41
|
-
if hash[:Key_name] == "PRIMARY"
|
42
|
-
hash[:Key_name] = PRIMARY_KEY_NAME
|
43
|
-
end
|
44
|
-
yield hash
|
45
|
-
end
|
46
|
-
end
|
47
|
-
end
|
48
|
-
connection.indexes(t).map do |i|
|
49
|
-
new(model, i.columns, name: i.name, unique: i.unique, where: i.where, table_name: old_table_name) unless model.ignore_indexes.include?(i.name)
|
50
|
-
end.compact
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
def primary_key?
|
55
|
-
name == PRIMARY_KEY_NAME
|
56
|
-
end
|
57
|
-
|
58
|
-
def to_add_statement(new_table_name, existing_primary_key = nil)
|
59
|
-
if primary_key? && !ActiveRecord::Base.connection.class.name.match?(/SQLite3Adapter/)
|
60
|
-
to_add_primary_key_statement(new_table_name, existing_primary_key)
|
61
|
-
else
|
62
|
-
r = +"add_index #{new_table_name.to_sym.inspect}, #{fields.map(&:to_sym).inspect}"
|
63
|
-
r += ", unique: true" if unique
|
64
|
-
r += ", where: '#{where}'" if where.present?
|
65
|
-
r += ", name: '#{name}'"
|
66
|
-
r
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
|
-
def to_add_primary_key_statement(new_table_name, existing_primary_key)
|
71
|
-
drop = "DROP PRIMARY KEY, " if existing_primary_key
|
72
|
-
statement = "ALTER TABLE #{new_table_name} #{drop}ADD PRIMARY KEY (#{fields.join(', ')})"
|
73
|
-
"execute #{statement.inspect}"
|
74
|
-
end
|
75
|
-
|
76
|
-
def to_key
|
77
|
-
@key ||= [table, fields, name, unique, where].map(&:to_s)
|
78
|
-
end
|
79
|
-
|
80
|
-
def settings
|
81
|
-
@settings ||= [table, fields, unique].map(&:to_s)
|
82
|
-
end
|
83
|
-
|
84
|
-
def hash
|
85
|
-
to_key.hash
|
86
|
-
end
|
87
|
-
|
88
|
-
def <=>(rhs)
|
89
|
-
to_key <=> rhs.to_key
|
90
|
-
end
|
91
|
-
|
92
|
-
def equivalent?(rhs)
|
93
|
-
settings == rhs.settings
|
94
|
-
end
|
95
|
-
|
96
|
-
def with_name(new_name)
|
97
|
-
self.class.new(@model, @fields, table_name: @table_name, index_name: @index_name, unique: @unique, name: new_name)
|
98
|
-
end
|
99
|
-
|
100
|
-
alias eql? ==
|
101
|
-
end
|
102
|
-
|
103
|
-
class ForeignKeySpec
|
104
|
-
include Comparable
|
105
|
-
|
106
|
-
attr_reader :constraint_name, :model, :foreign_key, :options, :on_delete_cascade
|
107
|
-
|
108
|
-
def initialize(model, foreign_key, options = {})
|
109
|
-
@model = model
|
110
|
-
@foreign_key = foreign_key.presence
|
111
|
-
@options = options
|
112
|
-
|
113
|
-
@child_table = model.table_name # unless a table rename, which would happen when a class is renamed??
|
114
|
-
@parent_table_name = options[:parent_table]
|
115
|
-
@foreign_key_name = options[:foreign_key] || self.foreign_key
|
116
|
-
@index_name = options[:index_name] || model.connection.index_name(model.table_name, column: foreign_key)
|
117
|
-
@constraint_name = options[:constraint_name] || @index_name || ''
|
118
|
-
@on_delete_cascade = options[:dependent] == :delete
|
119
|
-
|
120
|
-
# Empty constraint lets mysql generate the name
|
121
|
-
end
|
122
|
-
|
123
|
-
class << self
|
124
|
-
def for_model(model, old_table_name)
|
125
|
-
show_create_table = model.connection.select_rows("show create table #{model.connection.quote_table_name(old_table_name)}").first.last
|
126
|
-
constraints = show_create_table.split("\n").map { |line| line.strip if line['CONSTRAINT'] }.compact
|
127
|
-
|
128
|
-
constraints.map do |fkc|
|
129
|
-
options = {}
|
130
|
-
name, foreign_key, parent_table = fkc.match(/CONSTRAINT `([^`]*)` FOREIGN KEY \(`([^`]*)`\) REFERENCES `([^`]*)`/).captures
|
131
|
-
options[:constraint_name] = name
|
132
|
-
options[:parent_table] = parent_table
|
133
|
-
options[:foreign_key] = foreign_key
|
134
|
-
options[:dependent] = :delete if fkc['ON DELETE CASCADE']
|
135
|
-
|
136
|
-
new(model, foreign_key, options)
|
137
|
-
end
|
138
|
-
end
|
139
|
-
end
|
140
|
-
|
141
|
-
def parent_table_name
|
142
|
-
@parent_table_name ||=
|
143
|
-
options[:class_name]&.is_a?(Class) &&
|
144
|
-
options[:class_name].respond_to?(:table_name) &&
|
145
|
-
options[:class_name]&.table_name
|
146
|
-
@parent_table_name ||=
|
147
|
-
options[:class_name]&.constantize &&
|
148
|
-
options[:class_name].constantize.respond_to?(:table_name) &&
|
149
|
-
options[:class_name].constantize.table_name ||
|
150
|
-
foreign_key.gsub(/_id/, '').camelize.constantize.table_name
|
151
|
-
end
|
152
|
-
|
153
|
-
attr_writer :parent_table_name
|
154
|
-
|
155
|
-
def to_add_statement(_ = true)
|
156
|
-
statement = "ALTER TABLE #{@child_table} ADD CONSTRAINT #{@constraint_name} FOREIGN KEY #{@index_name}(#{@foreign_key_name}) REFERENCES #{parent_table_name}(id) #{'ON DELETE CASCADE' if on_delete_cascade}"
|
157
|
-
"execute #{statement.inspect}"
|
158
|
-
end
|
159
|
-
|
160
|
-
def to_key
|
161
|
-
@key ||= [@child_table, parent_table_name, @foreign_key_name, @on_delete_cascade].map(&:to_s)
|
162
|
-
end
|
163
|
-
|
164
|
-
def hash
|
165
|
-
to_key.hash
|
166
|
-
end
|
167
|
-
|
168
|
-
def <=>(rhs)
|
169
|
-
to_key <=> rhs.to_key
|
170
|
-
end
|
171
|
-
|
172
|
-
alias eql? ==
|
173
|
-
end
|
174
|
-
end
|
175
|
-
end
|