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.
@@ -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
@@ -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.3.0.pre.1
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-10-30 00:00:00.000000000 Z
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/index_spec.rb
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