declare_schema 0.3.0 → 0.5.0.pre.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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record/connection_adapters/mysql2_adapter'
4
+ require_relative '../../../../lib/declare_schema/model/table_options_definition'
5
+
6
+ RSpec.describe DeclareSchema::Model::TableOptionsDefinition do
7
+ before do
8
+ load File.expand_path('../prepare_testapp.rb', __dir__)
9
+
10
+ class TableOptionsDefinitionTestModel < ActiveRecord::Base
11
+ fields do
12
+ name :string, limit: 127, index: true
13
+ end
14
+ end
15
+ end
16
+
17
+ let(:model_class) { TableOptionsDefinitionTestModel }
18
+
19
+ context 'instance methods' do
20
+ let(:table_options) { { charset: "utf8", collation: "utf8_general"} }
21
+ let(:model) { described_class.new('table_options_definition_test_models', table_options) }
22
+
23
+ describe '#to_key' do
24
+ subject { model.to_key }
25
+ it { should eq(["table_options_definition_test_models", "{:charset=>\"utf8\", :collation=>\"utf8_general\"}"]) }
26
+ end
27
+
28
+ describe '#settings' do
29
+ subject { model.settings }
30
+ it { should eq("CHARACTER SET utf8 COLLATE utf8_general") }
31
+ end
32
+
33
+ describe '#hash' do
34
+ subject { model.hash }
35
+ it { should eq(["table_options_definition_test_models", "{:charset=>\"utf8\", :collation=>\"utf8_general\"}"].hash) }
36
+ end
37
+
38
+ describe '#to_s' do
39
+ subject { model.to_s }
40
+ it { should eq("CHARACTER SET utf8 COLLATE utf8_general") }
41
+ end
42
+
43
+ describe '#alter_table_statement' do
44
+ subject { model.alter_table_statement }
45
+ it { should eq('execute "ALTER TABLE \"table_options_definition_test_models\" CHARACTER SET utf8 COLLATE utf8_general;"') }
46
+ end
47
+ end
48
+
49
+
50
+ context 'class << self' do
51
+ describe '#for_model' do
52
+ context 'when using a SQLite connection' do
53
+ subject { described_class.for_model(model_class) }
54
+ it { should eq(described_class.new(model_class.table_name, {})) }
55
+ end
56
+ # TODO: Convert these tests to run against a MySQL database so that we can
57
+ # perform them without mocking out so much
58
+ context 'when using a MySQL connection' do
59
+ before do
60
+ double(ActiveRecord::ConnectionAdapters::Mysql2Adapter).tap do |stub_connection|
61
+ expect(stub_connection).to receive(:class).and_return(ActiveRecord::ConnectionAdapters::Mysql2Adapter)
62
+ expect(stub_connection).to receive(:current_database).and_return('test_database')
63
+ expect(stub_connection).to receive(:quote_string).with('test_database').and_return('"test_database"')
64
+ expect(stub_connection).to receive(:quote_string).with(model_class.table_name).and_return("\"#{model_class.table_name}\"")
65
+ expect(stub_connection).to(
66
+ receive(:select_one).with(<<~EOS)
67
+ SELECT CCSA.character_set_name, CCSA.collation_name
68
+ FROM information_schema.`TABLES` T, information_schema.`COLLATION_CHARACTER_SET_APPLICABILITY` CCSA
69
+ WHERE CCSA.collation_name = T.table_collation AND
70
+ T.table_schema = "test_database" AND
71
+ T.table_name = "#{model_class.table_name}";
72
+ EOS
73
+ .and_return({ "character_set_name" => "utf8", "collation_name" => "utf8_general" })
74
+ )
75
+ allow(model_class).to receive(:connection).and_return(stub_connection)
76
+ end
77
+ end
78
+
79
+ subject { described_class.for_model(model_class) }
80
+ it { should eq(described_class.new(model_class.table_name, { charset: "utf8", collation: "utf8_general" })) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -43,6 +43,34 @@ module Generators
43
43
  end
44
44
  end
45
45
 
46
+ describe '#default_charset' do
47
+ subject { described_class.default_charset }
48
+
49
+ context 'when not explicitly set' do
50
+ it { should eq(:utf8mb4) }
51
+ end
52
+
53
+ context 'when explicitly set' do
54
+ before { described_class.default_charset = :utf8 }
55
+ after { described_class.default_charset = described_class::DEFAULT_CHARSET }
56
+ it { should eq(:utf8) }
57
+ end
58
+ end
59
+
60
+ describe '#default_collation' do
61
+ subject { described_class.default_collation }
62
+
63
+ context 'when not explicitly set' do
64
+ it { should eq(:utf8mb4_general) }
65
+ end
66
+
67
+ context 'when explicitly set' do
68
+ before { described_class.default_collation = :utf8mb4_general_ci }
69
+ after { described_class.default_collation = described_class::DEFAULT_COLLATION }
70
+ it { should eq(:utf8mb4_general_ci) }
71
+ end
72
+ end
73
+
46
74
  describe 'load_rails_models' do
47
75
  before do
48
76
  expect(Rails.application).to receive(:eager_load!)
@@ -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
4
+ version: 0.5.0.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Invoca Development adapted from hobo_fields by Tom Locke
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-02 00:00:00.000000000 Z
11
+ date: 2020-12-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -32,7 +32,7 @@ executables:
32
32
  extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
- - ".dependabot/config.yml"
35
+ - ".github/dependabot.yml"
36
36
  - ".github/workflows/gem_release.yml"
37
37
  - ".gitignore"
38
38
  - ".rspec"
@@ -59,7 +59,9 @@ 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
64
+ - lib/declare_schema/model/table_options_definition.rb
63
65
  - lib/declare_schema/railtie.rb
64
66
  - lib/declare_schema/version.rb
65
67
  - lib/generators/declare_schema/migration/USAGE
@@ -76,15 +78,18 @@ files:
76
78
  - spec/lib/declare_schema/generator_spec.rb
77
79
  - spec/lib/declare_schema/interactive_primary_key_spec.rb
78
80
  - spec/lib/declare_schema/migration_generator_spec.rb
81
+ - spec/lib/declare_schema/model/index_definition_spec.rb
82
+ - spec/lib/declare_schema/model/table_options_definition_spec.rb
79
83
  - spec/lib/declare_schema/prepare_testapp.rb
80
84
  - spec/lib/generators/declare_schema/migration/migrator_spec.rb
81
85
  - spec/spec_helper.rb
86
+ - spec/support/acceptance_spec_helpers.rb
82
87
  - test_responses.txt
83
88
  homepage: https://github.com/Invoca/declare_schema
84
89
  licenses: []
85
90
  metadata:
86
91
  allowed_push_host: https://rubygems.org
87
- post_install_message:
92
+ post_install_message:
88
93
  rdoc_options: []
89
94
  require_paths:
90
95
  - lib
@@ -100,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
105
  version: 1.3.6
101
106
  requirements: []
102
107
  rubygems_version: 3.0.3
103
- signing_key:
108
+ signing_key:
104
109
  specification_version: 4
105
110
  summary: Database migration generator for Rails
106
111
  test_files: []
@@ -1,10 +0,0 @@
1
- ---
2
- version: 1
3
- update_configs:
4
- - package_manager: "ruby:bundler"
5
- directory: "/"
6
- update_schedule: "weekly"
7
- version_requirement_updates: "off"
8
- commit_message:
9
- prefix: "No-Jira"
10
- include_scope: true
@@ -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