declare_schema 0.3.0 → 0.5.0.pre.1

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,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