torque-postgresql 4.0.1 → 4.1.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.
Files changed (218) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +52 -0
  3. data/lib/generators/torque/skill_generator.rb +17 -0
  4. data/lib/generators/torque/templates/trigger.sql.erb +3 -0
  5. data/lib/generators/torque/trigger_generator.rb +13 -0
  6. data/lib/torque/postgresql/adapter/database_statements.rb +98 -7
  7. data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
  8. data/lib/torque/postgresql/adapter/oid/array.rb +13 -0
  9. data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
  10. data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
  11. data/lib/torque/postgresql/adapter/oid/composite.rb +117 -0
  12. data/lib/torque/postgresql/adapter/oid/enum.rb +1 -1
  13. data/lib/torque/postgresql/adapter/oid/enum_set.rb +2 -2
  14. data/lib/torque/postgresql/adapter/oid/interval.rb +1 -1
  15. data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
  16. data/lib/torque/postgresql/adapter/oid/lquery.rb +70 -0
  17. data/lib/torque/postgresql/adapter/oid/ltree.rb +111 -0
  18. data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
  19. data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
  20. data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
  21. data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
  22. data/lib/torque/postgresql/adapter/oid.rb +13 -0
  23. data/lib/torque/postgresql/adapter/quoting.rb +11 -2
  24. data/lib/torque/postgresql/adapter/schema_definitions.rb +51 -0
  25. data/lib/torque/postgresql/adapter/schema_dumper.rb +27 -0
  26. data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
  27. data/lib/torque/postgresql/adapter.rb +2 -5
  28. data/lib/torque/postgresql/arel/nodes.rb +114 -1
  29. data/lib/torque/postgresql/arel/visitors.rb +65 -8
  30. data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
  31. data/lib/torque/postgresql/associations.rb +3 -0
  32. data/lib/torque/postgresql/attributes/base.rb +97 -0
  33. data/lib/torque/postgresql/attributes/composite.rb +98 -0
  34. data/lib/torque/postgresql/attributes/enum.rb +13 -7
  35. data/lib/torque/postgresql/attributes/lquery.rb +120 -0
  36. data/lib/torque/postgresql/attributes/ltree.rb +121 -0
  37. data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
  38. data/lib/torque/postgresql/attributes/struct.rb +137 -0
  39. data/lib/torque/postgresql/base.rb +26 -15
  40. data/lib/torque/postgresql/config.rb +100 -15
  41. data/lib/torque/postgresql/geometry_builder.rb +4 -10
  42. data/lib/torque/postgresql/inheritance/expander.rb +66 -0
  43. data/lib/torque/postgresql/inheritance/record.rb +52 -0
  44. data/lib/torque/postgresql/inheritance.rb +141 -68
  45. data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
  46. data/lib/torque/postgresql/predicate_builder/arel_attribute_handler.rb +1 -1
  47. data/lib/torque/postgresql/predicate_builder/array_handler.rb +16 -8
  48. data/lib/torque/postgresql/predicate_builder/composite_handler.rb +122 -0
  49. data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +112 -0
  50. data/lib/torque/postgresql/predicate_builder/struct_handler.rb +88 -0
  51. data/lib/torque/postgresql/predicate_builder.rb +53 -0
  52. data/lib/torque/postgresql/predicate_table.rb +61 -0
  53. data/lib/torque/postgresql/railtie.rb +52 -0
  54. data/lib/torque/postgresql/relation/auxiliary_statement.rb +7 -3
  55. data/lib/torque/postgresql/relation/buckets.rb +2 -1
  56. data/lib/torque/postgresql/relation/cross_join.rb +193 -0
  57. data/lib/torque/postgresql/relation/inheritance.rb +145 -28
  58. data/lib/torque/postgresql/relation/join_series.rb +1 -0
  59. data/lib/torque/postgresql/relation/merger.rb +30 -5
  60. data/lib/torque/postgresql/relation/segments.rb +140 -0
  61. data/lib/torque/postgresql/relation.rb +32 -12
  62. data/lib/torque/postgresql/schema_cache.rb +1 -0
  63. data/lib/torque/postgresql/validations.rb +29 -0
  64. data/lib/torque/postgresql/version.rb +1 -1
  65. data/lib/torque/postgresql/versioned_commands/command_migration.rb +24 -1
  66. data/lib/torque/postgresql/versioned_commands.rb +39 -0
  67. data/lib/torque/postgresql.rb +4 -0
  68. data/sig/generators/torque/function_generator.rbs +9 -0
  69. data/sig/generators/torque/skill_generator.rbs +7 -0
  70. data/sig/generators/torque/trigger_generator.rbs +9 -0
  71. data/sig/generators/torque/type_generator.rbs +9 -0
  72. data/sig/generators/torque/view_generator.rbs +9 -0
  73. data/sig/manifest.yaml +7 -0
  74. data/sig/torque/postgresql/adapter/database_statements.rbs +42 -0
  75. data/sig/torque/postgresql/adapter/inheritance_statements.rbs +36 -0
  76. data/sig/torque/postgresql/adapter/oid/array.rbs +20 -0
  77. data/sig/torque/postgresql/adapter/oid/box.rbs +28 -0
  78. data/sig/torque/postgresql/adapter/oid/circle.rbs +33 -0
  79. data/sig/torque/postgresql/adapter/oid/composite.rbs +35 -0
  80. data/sig/torque/postgresql/adapter/oid/enum.rbs +28 -0
  81. data/sig/torque/postgresql/adapter/oid/enum_set.rbs +19 -0
  82. data/sig/torque/postgresql/adapter/oid/interval.rbs +20 -0
  83. data/sig/torque/postgresql/adapter/oid/line.rbs +33 -0
  84. data/sig/torque/postgresql/adapter/oid/lquery.rbs +21 -0
  85. data/sig/torque/postgresql/adapter/oid/ltree.rbs +32 -0
  86. data/sig/torque/postgresql/adapter/oid/range.rbs +28 -0
  87. data/sig/torque/postgresql/adapter/oid/segment.rbs +38 -0
  88. data/sig/torque/postgresql/adapter/oid/struct.rbs +41 -0
  89. data/sig/torque/postgresql/adapter/oid/struct_list.rbs +20 -0
  90. data/sig/torque/postgresql/adapter/oid/struct_set.rbs +18 -0
  91. data/sig/torque/postgresql/adapter/oid.rbs +9 -0
  92. data/sig/torque/postgresql/adapter/quoting.rbs +20 -0
  93. data/sig/torque/postgresql/adapter/schema_creation.rbs +13 -0
  94. data/sig/torque/postgresql/adapter/schema_definitions.rbs +55 -0
  95. data/sig/torque/postgresql/adapter/schema_dumper.rbs +37 -0
  96. data/sig/torque/postgresql/adapter/schema_overrides.rbs +46 -0
  97. data/sig/torque/postgresql/adapter/schema_statements.rbs +50 -0
  98. data/sig/torque/postgresql/adapter.rbs +24 -0
  99. data/sig/torque/postgresql/arel/infix_operation.rbs +79 -0
  100. data/sig/torque/postgresql/arel/join_source.rbs +19 -0
  101. data/sig/torque/postgresql/arel/nodes.rbs +83 -0
  102. data/sig/torque/postgresql/arel/operations.rbs +28 -0
  103. data/sig/torque/postgresql/arel/select_manager.rbs +15 -0
  104. data/sig/torque/postgresql/arel/visitors.rbs +30 -0
  105. data/sig/torque/postgresql/arel.rbs +6 -0
  106. data/sig/torque/postgresql/associations/association_scope.rbs +28 -0
  107. data/sig/torque/postgresql/associations/belongs_to_many_association.rbs +51 -0
  108. data/sig/torque/postgresql/associations/builder/belongs_to_many.rbs +24 -0
  109. data/sig/torque/postgresql/associations/builder/has_many.rbs +15 -0
  110. data/sig/torque/postgresql/associations/builder.rbs +8 -0
  111. data/sig/torque/postgresql/associations/foreign_association.rbs +16 -0
  112. data/sig/torque/postgresql/associations/join_dependency.rbs +16 -0
  113. data/sig/torque/postgresql/associations/preloader/association.rbs +32 -0
  114. data/sig/torque/postgresql/associations/preloader/loader_query.rbs +20 -0
  115. data/sig/torque/postgresql/associations/preloader.rbs +8 -0
  116. data/sig/torque/postgresql/associations.rbs +17 -0
  117. data/sig/torque/postgresql/attributes/base.rbs +37 -0
  118. data/sig/torque/postgresql/attributes/builder/enum.rbs +38 -0
  119. data/sig/torque/postgresql/attributes/builder/full_text_search.rbs +27 -0
  120. data/sig/torque/postgresql/attributes/builder/period.rbs +106 -0
  121. data/sig/torque/postgresql/attributes/builder.rbs +19 -0
  122. data/sig/torque/postgresql/attributes/composite.rbs +28 -0
  123. data/sig/torque/postgresql/attributes/enum.rbs +69 -0
  124. data/sig/torque/postgresql/attributes/enum_set.rbs +75 -0
  125. data/sig/torque/postgresql/attributes/full_text_search.rbs +13 -0
  126. data/sig/torque/postgresql/attributes/lazy.rbs +17 -0
  127. data/sig/torque/postgresql/attributes/lquery.rbs +48 -0
  128. data/sig/torque/postgresql/attributes/ltree.rbs +46 -0
  129. data/sig/torque/postgresql/attributes/period.rbs +13 -0
  130. data/sig/torque/postgresql/attributes/simple_enum.rbs +39 -0
  131. data/sig/torque/postgresql/attributes/struct.rbs +38 -0
  132. data/sig/torque/postgresql/attributes.rbs +6 -0
  133. data/sig/torque/postgresql/autosave_association.rbs +16 -0
  134. data/sig/torque/postgresql/auxiliary_statement/recursive.rbs +22 -0
  135. data/sig/torque/postgresql/auxiliary_statement/settings.rbs +63 -0
  136. data/sig/torque/postgresql/auxiliary_statement.rbs +57 -0
  137. data/sig/torque/postgresql/base.rbs +41 -0
  138. data/sig/torque/postgresql/collector.rbs +7 -0
  139. data/sig/torque/postgresql/config.rbs +9 -0
  140. data/sig/torque/postgresql/function.rbs +21 -0
  141. data/sig/torque/postgresql/geometry_builder.rbs +20 -0
  142. data/sig/torque/postgresql/i18n.rbs +15 -0
  143. data/sig/torque/postgresql/inheritance/expander.rbs +23 -0
  144. data/sig/torque/postgresql/inheritance/record.rbs +20 -0
  145. data/sig/torque/postgresql/inheritance.rbs +47 -0
  146. data/sig/torque/postgresql/insert_all.rbs +22 -0
  147. data/sig/torque/postgresql/migration/command_recorder.rbs +30 -0
  148. data/sig/torque/postgresql/migration.rbs +6 -0
  149. data/sig/torque/postgresql/predicate_builder/arel_attribute_handler.rbs +15 -0
  150. data/sig/torque/postgresql/predicate_builder/array_handler.rbs +26 -0
  151. data/sig/torque/postgresql/predicate_builder/composite_handler.rbs +26 -0
  152. data/sig/torque/postgresql/predicate_builder/enumerator_lazy_handler.rbs +18 -0
  153. data/sig/torque/postgresql/predicate_builder/ltree_handler.rbs +36 -0
  154. data/sig/torque/postgresql/predicate_builder/regexp_handler.rbs +16 -0
  155. data/sig/torque/postgresql/predicate_builder/struct_handler.rbs +24 -0
  156. data/sig/torque/postgresql/predicate_builder.rbs +23 -0
  157. data/sig/torque/postgresql/predicate_table.rbs +24 -0
  158. data/sig/torque/postgresql/railtie.rbs +10 -0
  159. data/sig/torque/postgresql/reflection/abstract_reflection.rbs +22 -0
  160. data/sig/torque/postgresql/reflection/association_reflection.rbs +18 -0
  161. data/sig/torque/postgresql/reflection/belongs_to_many_reflection.rbs +30 -0
  162. data/sig/torque/postgresql/reflection/has_many_reflection.rbs +14 -0
  163. data/sig/torque/postgresql/reflection/runtime_reflection.rbs +18 -0
  164. data/sig/torque/postgresql/reflection/through_reflection.rbs +14 -0
  165. data/sig/torque/postgresql/reflection.rbs +11 -0
  166. data/sig/torque/postgresql/relation/auxiliary_statement.rbs +29 -0
  167. data/sig/torque/postgresql/relation/buckets.rbs +35 -0
  168. data/sig/torque/postgresql/relation/cross_join.rbs +63 -0
  169. data/sig/torque/postgresql/relation/distinct_on.rbs +16 -0
  170. data/sig/torque/postgresql/relation/inheritance.rbs +48 -0
  171. data/sig/torque/postgresql/relation/join_series.rbs +28 -0
  172. data/sig/torque/postgresql/relation/merger.rbs +23 -0
  173. data/sig/torque/postgresql/relation/segments.rbs +27 -0
  174. data/sig/torque/postgresql/relation.rbs +48 -0
  175. data/sig/torque/postgresql/schema_cache/bound_schema_reflection.rbs +14 -0
  176. data/sig/torque/postgresql/schema_cache/inheritance.rbs +12 -0
  177. data/sig/torque/postgresql/schema_cache/schema_reflection.rbs +14 -0
  178. data/sig/torque/postgresql/schema_cache.rbs +43 -0
  179. data/sig/torque/postgresql/table_name.rbs +20 -0
  180. data/sig/torque/postgresql/validations.rbs +13 -0
  181. data/sig/torque/postgresql/version.rbs +5 -0
  182. data/sig/torque/postgresql/versioned_commands/command_migration.rbs +54 -0
  183. data/sig/torque/postgresql/versioned_commands/generator.rbs +17 -0
  184. data/sig/torque/postgresql/versioned_commands/migration_context.rbs +30 -0
  185. data/sig/torque/postgresql/versioned_commands/migrator.rbs +19 -0
  186. data/sig/torque/postgresql/versioned_commands/schema_table.rbs +23 -0
  187. data/sig/torque/postgresql/versioned_commands.rbs +25 -0
  188. data/sig/torque/postgresql.rbs +26 -0
  189. data/skills/torque-postgresql/SKILL.md +70 -0
  190. data/spec/fixtures/migrations/20250101000008_create_trigger_audit_users_v1.sql +3 -0
  191. data/spec/initialize.rb +29 -2
  192. data/spec/mocks/cache_query.rb +13 -1
  193. data/spec/models/author.rb +1 -1
  194. data/spec/models/comment.rb +2 -0
  195. data/spec/models/place.rb +2 -0
  196. data/spec/models/profile.rb +31 -0
  197. data/spec/rbs_helper.rb +8 -0
  198. data/spec/schema.rb +35 -4
  199. data/spec/spec_helper.rb +1 -0
  200. data/spec/tests/arel_spec.rb +3 -3
  201. data/spec/tests/auxiliary_statement_spec.rb +12 -0
  202. data/spec/tests/buckets_spec.rb +122 -0
  203. data/spec/tests/composite_spec.rb +867 -0
  204. data/spec/tests/cross_join_spec.rb +185 -0
  205. data/spec/tests/distinct_on_spec.rb +5 -0
  206. data/spec/tests/enum_set_spec.rb +4 -0
  207. data/spec/tests/enum_spec.rb +20 -4
  208. data/spec/tests/geometric_builder_spec.rb +16 -5
  209. data/spec/tests/has_many_spec.rb +2 -1
  210. data/spec/tests/interval_spec.rb +6 -0
  211. data/spec/tests/ltree_spec.rb +886 -0
  212. data/spec/tests/predicate_builder_spec.rb +120 -0
  213. data/spec/tests/relation_spec.rb +25 -69
  214. data/spec/tests/segments_spec.rb +264 -0
  215. data/spec/tests/struct_spec.rb +1170 -0
  216. data/spec/tests/table_inheritance_spec.rb +1037 -56
  217. data/spec/tests/versioned_commands_spec.rb +145 -0
  218. metadata +198 -2
@@ -0,0 +1,867 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe 'Composite' do
4
+ let(:connection) { ActiveRecord::Base.connection }
5
+ let(:source) { ActiveRecord::Base.connection_pool }
6
+
7
+ context 'on migration' do
8
+ it 'creates a composite type' do
9
+ connection.create_composite_type(:sample_type) do |t|
10
+ t.string 'label'
11
+ t.integer 'amount'
12
+ end
13
+
14
+ expect(connection.type_exists?(:sample_type)).to be_truthy
15
+
16
+ columns = connection.composite_column_types('sample_type')
17
+ expect(columns.keys).to be_eql(%w[label amount])
18
+ expect(columns['amount'].type).to be_eql(:integer)
19
+ ensure
20
+ connection.drop_type(:sample_type)
21
+ end
22
+
23
+ it 'accepts size-related and type-related options' do
24
+ connection.create_composite_type(:sample_type) do |t|
25
+ t.string 'label', limit: 10
26
+ t.enum 'category', enum_type: :types
27
+ t.composite 'place', composite_type: :address
28
+ t.string 'tags', array: true
29
+ end
30
+
31
+ columns = connection.composite_column_types('sample_type')
32
+ expect(columns['category']).to be_a(Torque::PostgreSQL::Adapter::OID::Enum)
33
+ expect(columns['place']).to be_a(Torque::PostgreSQL::Adapter::OID::Composite)
34
+ ensure
35
+ connection.drop_type(:sample_type)
36
+ end
37
+
38
+ it 'raises on unsupported column options' do
39
+ expect do
40
+ connection.create_composite_type(:sample_type) do |t|
41
+ t.string 'label', null: false
42
+ end
43
+ end.to raise_error(ArgumentError, /unsupported options/)
44
+
45
+ expect do
46
+ connection.create_composite_type(:sample_type) do |t|
47
+ t.string 'label', default: 'x'
48
+ end
49
+ end.to raise_error(ArgumentError, /unsupported options/)
50
+ end
51
+
52
+ it 'raises on indexes' do
53
+ expect do
54
+ connection.create_composite_type(:sample_type) do |t|
55
+ t.string 'label', index: true
56
+ end
57
+ end.to raise_error(ArgumentError)
58
+ end
59
+
60
+ it 'raises when composite columns do not provide the type' do
61
+ expect { connection.type_to_sql(:composite) }
62
+ .to raise_error(ArgumentError, /composite_type is required/)
63
+ end
64
+
65
+ it 'raises when the type already exists' do
66
+ expect do
67
+ connection.create_composite_type(:address) { |t| t.string 'other' }
68
+ end.to raise_error(ActiveRecord::StatementInvalid, /already exists/)
69
+ end
70
+
71
+ it 'changes the columns of a composite type' do
72
+ connection.create_composite_type(:sample_type) do |t|
73
+ t.string 'label'
74
+ t.integer 'amount'
75
+ t.string 'gone'
76
+ end
77
+
78
+ connection.change_composite_type(:sample_type) do |t|
79
+ t.date 'issued_at'
80
+ t.change 'amount', :bigint
81
+ t.remove 'gone'
82
+ t.rename 'label', 'title'
83
+ end
84
+
85
+ columns = connection.composite_column_types('sample_type')
86
+ expect(columns.keys).to be_eql(%w[title amount issued_at])
87
+ expect(columns['amount'].type).to be_eql(:integer)
88
+ expect(columns['amount'].limit).to be_eql(8)
89
+ expect(columns['issued_at'].type).to be_eql(:date)
90
+ ensure
91
+ connection.drop_type(:sample_type, check: true)
92
+ end
93
+
94
+ it 'keeps the schema out of the columns while changing a type' do
95
+ connection.create_composite_type(:sample_type, schema: 'internal') { |t| t.string 'label' }
96
+ connection.add_composite_column(:sample_type, 'amount', :integer, schema: 'internal')
97
+
98
+ columns = connection.composite_column_types('internal.sample_type')
99
+ expect(columns.keys).to be_eql(%w[label amount])
100
+ ensure
101
+ connection.drop_type(:sample_type, schema: 'internal', check: true)
102
+ end
103
+
104
+ it 'raises on unsupported options when changing a composite type' do
105
+ expect do
106
+ connection.change_composite_type(:sample_type) { |t| t.string 'label', null: false }
107
+ end.to raise_error(ArgumentError, /unsupported options/)
108
+ end
109
+
110
+ it 'changes a composite type one column at a time' do
111
+ connection.create_composite_type(:sample_type) { |t| t.string 'label' }
112
+
113
+ connection.add_composite_column(:sample_type, 'amount', :integer)
114
+ connection.change_composite_column(:sample_type, 'amount', :bigint)
115
+ connection.rename_composite_column(:sample_type, 'label', 'title')
116
+ connection.add_composite_column(:sample_type, 'gone', :string)
117
+ connection.remove_composite_column(:sample_type, 'gone')
118
+
119
+ columns = connection.composite_column_types('sample_type')
120
+ expect(columns.keys).to be_eql(%w[title amount])
121
+ expect(columns['amount'].limit).to be_eql(8)
122
+ ensure
123
+ connection.drop_type(:sample_type, check: true)
124
+ end
125
+
126
+ it 'recreates the type with force' do
127
+ connection.create_composite_type(:sample_type) { |t| t.string 'a' }
128
+ connection.create_composite_type(:sample_type, force: :cascade) { |t| t.string 'b' }
129
+
130
+ expect(connection.composite_column_types('sample_type').keys).to be_eql(%w[b])
131
+ ensure
132
+ connection.drop_type(:sample_type)
133
+ end
134
+
135
+ context 'reverting' do
136
+ let(:migration) { ActiveRecord::Migration::Current.new('Testing') }
137
+
138
+ before do
139
+ allow_any_instance_of(ActiveRecord::Migration).to receive(:puts)
140
+ connection.create_composite_type(:sample_type) { |t| t.string 'label' }
141
+ end
142
+
143
+ it 'reverts the creation of a composite type' do
144
+ expect(connection.type_exists?(:sample_type)).to be_truthy
145
+
146
+ migration.revert do
147
+ migration.connection.create_composite_type(:sample_type) { |t| t.string 'label' }
148
+ end
149
+
150
+ expect(connection.type_exists?(:sample_type)).to be_falsey
151
+ end
152
+
153
+ it 'reverts a column being added to a composite type' do
154
+ migration.connection.add_composite_column(:sample_type, 'amount', :integer)
155
+ expect(connection.composite_column_types('sample_type').keys).to include('amount')
156
+
157
+ migration.revert do
158
+ migration.connection.add_composite_column(:sample_type, 'amount', :integer)
159
+ end
160
+
161
+ expect(connection.composite_column_types('sample_type').keys).not_to include('amount')
162
+ ensure
163
+ connection.drop_type(:sample_type, check: true)
164
+ end
165
+
166
+ it 'reverts a column being renamed' do
167
+ migration.revert do
168
+ migration.connection.rename_composite_column(:sample_type, 'title', 'label')
169
+ end
170
+
171
+ expect(connection.composite_column_types('sample_type').keys).to be_eql(%w[title])
172
+ ensure
173
+ connection.drop_type(:sample_type, check: true)
174
+ end
175
+
176
+ it 'does not revert a composite type being changed' do
177
+ expect do
178
+ migration.revert do
179
+ migration.connection.change_composite_type(:sample_type) { |t| t.string 'other' }
180
+ end
181
+ end.to raise_error(ActiveRecord::IrreversibleMigration)
182
+ ensure
183
+ connection.drop_type(:sample_type, check: true)
184
+ end
185
+
186
+ it 'does not revert a column being removed without a type' do
187
+ expect do
188
+ migration.revert do
189
+ migration.connection.remove_composite_column(:sample_type, 'gone')
190
+ end
191
+ end.to raise_error(ActiveRecord::IrreversibleMigration)
192
+ ensure
193
+ connection.drop_type(:sample_type, check: true)
194
+ end
195
+
196
+ it 'reverts a column being removed when given a type' do
197
+ migration.revert do
198
+ migration.connection.remove_composite_column(:sample_type, 'gone', :string)
199
+ end
200
+
201
+ expect(connection.composite_column_types('sample_type').keys).to include('gone')
202
+ ensure
203
+ connection.drop_type(:sample_type, check: true)
204
+ end
205
+ end
206
+
207
+ context 'with tables' do
208
+ before(:context) { ActiveRecord::Base.connection.max_identifier_length }
209
+
210
+ mock_create_table
211
+
212
+ it 'adds composite columns through the helper' do
213
+ sql = connection.create_table(:sample, id: false) do |t|
214
+ t.composite 'home', composite_type: :address
215
+ end
216
+
217
+ expect(sql).to include('"home" address')
218
+ end
219
+
220
+ it 'supports arrays of composite columns' do
221
+ sql = connection.create_table(:sample, id: false) do |t|
222
+ t.composite 'homes', composite_type: :address, array: true
223
+ end
224
+
225
+ expect(sql).to include('"homes" address[]')
226
+ end
227
+
228
+ it 'supports the type name as the column type' do
229
+ sql = connection.create_table(:sample, id: false) do |t|
230
+ t.column 'home', :address
231
+ end
232
+
233
+ expect(sql).to include('"home" address')
234
+ end
235
+ end
236
+ end
237
+
238
+ context 'on discovery' do
239
+ it 'lists user defined composite types' do
240
+ expect(connection.composite_types).to include('address', 'full_address')
241
+ end
242
+
243
+ it 'sorts the list by dependencies' do
244
+ list = connection.composite_types
245
+ expect(list.index('address')).to be < list.index('full_address')
246
+ end
247
+
248
+ it 'does not include table row types' do
249
+ expect(connection.composite_types & connection.tables).to be_empty
250
+ end
251
+
252
+ it 'identifies composite columns' do
253
+ column = Place.columns_hash['home']
254
+ expect(column.type).to be_eql(:composite)
255
+ expect(column.sql_type).to be_eql('address')
256
+
257
+ column = Place.columns_hash['offices']
258
+ expect(column.type).to be_eql(:composite)
259
+ expect(column.array?).to be_truthy
260
+ end
261
+ end
262
+
263
+ context 'on classes' do
264
+ it 'spins up classes on demand' do
265
+ klass = Composite::Address
266
+ expect(klass.superclass).to be_eql(Torque::PostgreSQL::Attributes::Composite)
267
+ expect(klass.type_name).to be_eql('address')
268
+ end
269
+
270
+ it 'defines the attributes from the type columns' do
271
+ instance = Composite::Address.new(street: 'Main', number: '42')
272
+ expect(instance.street).to be_eql('Main')
273
+ expect(instance.number).to be_eql(42)
274
+ expect(Composite::Address.attribute_names).to include('street', 'city', 'number', 'category')
275
+ end
276
+
277
+ it 'compares instances by class and attributes' do
278
+ one = Composite::Address.new(street: 'X', number: 1)
279
+ two = Composite::Address.new(street: 'X', number: 1)
280
+
281
+ expect(one).to be_eql(two)
282
+
283
+ two.number = 2
284
+ expect(one).not_to be_eql(two)
285
+ end
286
+
287
+ it 'exposes the attributes as a hash' do
288
+ instance = Composite::Address.new(street: 'X')
289
+ expect(instance.to_h).to include(street: 'X', number: nil)
290
+ end
291
+
292
+ it 'keeps the attributes that the class declares on its own' do
293
+ klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
294
+ klass.type_name = 'address'
295
+ klass.attribute(:number, :string)
296
+
297
+ expect(klass.attribute_types['number']).to be_a(ActiveModel::Type::String)
298
+ expect(klass.new(number: 42).number).to be_eql('42')
299
+ expect(klass.columns['number'].type).to be_eql(:integer)
300
+ end
301
+
302
+ it 'supports irregular types mapping' do
303
+ stub_klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
304
+ Object.const_set('SpecialAddress', stub_klass)
305
+ Torque::PostgreSQL.config.composite.irregular_types = { address: 'SpecialAddress' }
306
+
307
+ expect(Torque::PostgreSQL::Attributes::Composite.lookup('address')).to be_eql(stub_klass)
308
+ ensure
309
+ Torque::PostgreSQL.config.composite.irregular_types = {}
310
+ Object.send(:remove_const, 'SpecialAddress')
311
+ end
312
+ end
313
+
314
+ context 'on OID' do
315
+ subject { Torque::PostgreSQL::Adapter::OID::Composite.new('address') }
316
+
317
+ it 'deserializes literals with quoting edge cases' do
318
+ instance = subject.deserialize(%q{("say ""hi"", ok",,"1",A)})
319
+ expect(instance.street).to be_eql('say "hi", ok')
320
+ expect(instance.city).to be_nil
321
+ expect(instance.number).to be_eql(1)
322
+ expect(instance.category).to be_eql('A')
323
+ end
324
+
325
+ it 'differentiates nil and empty string columns' do
326
+ instance = subject.deserialize(%q{("",,,)})
327
+ expect(instance.street).to be_eql('')
328
+ expect(instance.city).to be_nil
329
+ end
330
+
331
+ it 'casts hashes, arrays, instances, and nil' do
332
+ expect(subject.cast(nil)).to be_nil
333
+ expect(subject.cast(street: 'X').street).to be_eql('X')
334
+ expect(subject.cast(['X', 'Y', 2, nil]).city).to be_eql('Y')
335
+
336
+ instance = Composite::Address.new(street: 'X')
337
+ expect(subject.cast(instance)).to be_eql(instance)
338
+ end
339
+
340
+ it 'serializes into an encoder and its values' do
341
+ data = subject.serialize(Composite::Address.new(street: 'X', number: 1))
342
+ expect(data).to be_a(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array::Data)
343
+ expect(data.encoder).to be_a(PG::TextEncoder::Record)
344
+ expect(data.values).to be_eql(['X', nil, 1, nil])
345
+ expect(subject.serialize(nil)).to be_nil
346
+ end
347
+
348
+ it 'deserializes back from what it serialized' do
349
+ instance = Composite::Address.new(street: 'X', number: 1)
350
+ expect(subject.deserialize(subject.serialize(instance))).to be_eql(instance)
351
+ end
352
+
353
+ it 'detects in-place changes' do
354
+ raw = %q{(Main,,1,)}
355
+ expect(subject.changed_in_place?(raw, subject.deserialize(raw))).to be_falsey
356
+ expect(subject.changed_in_place?(raw, Composite::Address.new(street: 'Other'))).to be_truthy
357
+ end
358
+ end
359
+
360
+ context 'on records' do
361
+ it 'round-trips composite values' do
362
+ place = Place.create!(name: 'HQ', home: { street: 'Main, St', number: 1, category: 'A' })
363
+ place.reload
364
+
365
+ expect(place.home).to be_a(Composite::Address)
366
+ expect(place.home.street).to be_eql('Main, St')
367
+ expect(place.home.number).to be_eql(1)
368
+ expect(place.home.category).to be_eql('A')
369
+ end
370
+
371
+ it 'round-trips tricky quoting values' do
372
+ tricky = { street: %q{say "hi", ok\maybe}, city: '', number: nil }
373
+ place = Place.create!(name: 'Edge', home: tricky)
374
+ place.reload
375
+
376
+ expect(place.home.street).to be_eql(%q{say "hi", ok\maybe})
377
+ expect(place.home.city).to be_eql('')
378
+ expect(place.home.number).to be_nil
379
+ end
380
+
381
+ it 'round-trips arrays of composite values' do
382
+ offices = [{ street: 'A' }, Composite::Address.new(street: 'B, "C"')]
383
+ place = Place.create!(name: 'Multi', offices: offices)
384
+ place.reload
385
+
386
+ expect(place.offices.size).to be_eql(2)
387
+ expect(place.offices.map(&:class).uniq).to be_eql([Composite::Address])
388
+ expect(place.offices.last.street).to be_eql('B, "C"')
389
+ end
390
+
391
+ it 'round-trips nested composite values' do
392
+ location = { base: { street: 'Deep', number: 7 }, country: 'BR' }
393
+ place = Place.create!(name: 'Nested', location: location)
394
+ place.reload
395
+
396
+ expect(place.location).to be_a(Composite::FullAddress)
397
+ expect(place.location.country).to be_eql('BR')
398
+ expect(place.location.base).to be_a(Composite::Address)
399
+ expect(place.location.base.number).to be_eql(7)
400
+ end
401
+
402
+ it 'round-trips columns that are not plain strings' do
403
+ location = { country: 'BR', since: Date.new(2020, 3, 1), rate: BigDecimal('12.34') }
404
+ place = Place.create!(name: 'Typed', location: location)
405
+ place.reload
406
+
407
+ expect(place.location.since).to be_eql(Date.new(2020, 3, 1))
408
+ expect(place.location.rate).to be_eql(BigDecimal('12.34'))
409
+ end
410
+
411
+ it 'does not mark untouched records as dirty' do
412
+ place = Place.create!(name: 'Clean', home: { street: 'S' })
413
+ place.reload
414
+
415
+ place.home.street
416
+ expect(place.changed?).to be_falsey
417
+ end
418
+
419
+ it 'marks assignment changes as dirty' do
420
+ place = Place.create!(name: 'Dirty', home: { street: 'S' })
421
+ place.reload
422
+
423
+ place.home = { street: 'Other' }
424
+ expect(place.changed?).to be_truthy
425
+
426
+ place.save!
427
+ expect(place.reload.home.street).to be_eql('Other')
428
+ end
429
+
430
+ it 'supports composite values on where clauses' do
431
+ place = Place.create!(name: 'Find', home: { street: 'Unique St', number: 9 })
432
+
433
+ found = Place.where(home: place.reload.home).first
434
+ expect(found).to be_eql(place)
435
+ end
436
+ end
437
+
438
+ context 'on null semantics' do
439
+ subject { Place.create!(name: 'Null').reload }
440
+
441
+ let(:raw_home) do
442
+ connection.select_value("SELECT home::text FROM places WHERE id = #{subject.id}")
443
+ end
444
+
445
+ it 'reads a null column as nil, which is blank' do
446
+ expect(subject.home).to be_nil
447
+ expect(subject.home).to be_blank
448
+ expect(subject.offices).to be_nil
449
+ expect(subject.offices).to be_blank
450
+ end
451
+
452
+ it 'keeps the column null when the record is saved again' do
453
+ subject.update!(name: 'Still null')
454
+ expect(raw_home).to be_nil
455
+ end
456
+
457
+ it 'does not validate a null value' do
458
+ expect(Place.new(name: 'Null')).to be_valid
459
+ end
460
+
461
+ # A composite always carries every one of its columns, so a row of nulls is
462
+ # a value on its own, which PostgreSQL keeps apart from a null column
463
+ it 'is never blank once there is a value' do
464
+ expect(Composite::Address.new).not_to be_empty
465
+ expect(Composite::Address.new).to be_present
466
+ end
467
+
468
+ it 'stores a value with no columns set as a row of nulls' do
469
+ subject.update!(home: {})
470
+
471
+ expect(raw_home).to be_eql('(,,,)')
472
+ expect(subject.reload.home).to be_a(Composite::Address)
473
+ expect(subject.home).to be_present
474
+ end
475
+ end
476
+
477
+ context 'on enum' do
478
+ let(:enum_klass) do
479
+ klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
480
+ klass.type_name = 'address'
481
+ klass.enum(:category, { alpha: 'A', beta: 'B' })
482
+ klass
483
+ end
484
+
485
+ it 'declares over the type loaded from the database' do
486
+ expect(enum_klass.attribute_types['category']).to be_a(ActiveRecord::Enum::EnumType)
487
+ expect(enum_klass.attribute_types['number'].type).to be_eql(:integer)
488
+ end
489
+
490
+ it 'casts the value both ways' do
491
+ instance = enum_klass.new(category: 'alpha')
492
+
493
+ expect(instance.category).to be_eql('alpha')
494
+ expect(instance.alpha?).to be_truthy
495
+ expect(instance.beta?).to be_falsey
496
+ end
497
+
498
+ it 'serializes through the composite type' do
499
+ type = Torque::PostgreSQL::Adapter::OID::Composite.new('address')
500
+ allow(type).to receive(:klass).and_return(enum_klass)
501
+
502
+ data = type.serialize(enum_klass.new(street: 'M', category: 'alpha'))
503
+ expect(data.values).to be_eql(['M', nil, nil, 'A'])
504
+ end
505
+
506
+ it 'does not define anything that needs a relation' do
507
+ expect(enum_klass.new).not_to respond_to(:alpha!)
508
+ expect(enum_klass).not_to respond_to(:alpha)
509
+ end
510
+ end
511
+
512
+ context 'on normalization' do
513
+ let(:extended_klass) do
514
+ klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
515
+ klass.type_name = 'address'
516
+ klass.normalizes(:street, with: ->(value) { value.strip.upcase })
517
+ klass
518
+ end
519
+
520
+ it 'normalizes a column on assignment' do
521
+ expect(extended_klass.new(street: ' main ').street).to be_eql('MAIN')
522
+ end
523
+ end
524
+
525
+ context 'on store accessor' do
526
+ let(:extended_klass) do
527
+ klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
528
+ klass.type_name = 'address'
529
+ klass.attribute(:extras, ActiveRecord::Type::Json.new)
530
+ klass.store_accessor(:extras, :locale)
531
+ klass
532
+ end
533
+
534
+ it 'expands the keys of a json attribute' do
535
+ instance = extended_klass.new
536
+ instance.locale = 'pt-BR'
537
+
538
+ expect(instance.locale).to be_eql('pt-BR')
539
+ expect(instance.extras).to be_eql('locale' => 'pt-BR')
540
+ expect(instance.locale_changed?).to be_truthy
541
+ end
542
+ end
543
+
544
+ context 'on json serialization' do
545
+ it 'serializes the columns, and not the internals' do
546
+ instance = Composite::Address.new(street: 'Main', number: 1)
547
+
548
+ expect(instance.as_json).to be_eql(
549
+ 'street' => 'Main',
550
+ 'city' => nil,
551
+ 'number' => 1,
552
+ 'category' => nil,
553
+ )
554
+ end
555
+ end
556
+
557
+ context 'on encryption' do
558
+ before do
559
+ ActiveRecord::Encryption.configure(
560
+ primary_key: 'test master key',
561
+ deterministic_key: 'test deterministic key',
562
+ key_derivation_salt: 'testing salt',
563
+ )
564
+ end
565
+
566
+ let(:composite_klass) do
567
+ klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
568
+ klass.type_name = 'address'
569
+ klass.encrypts :street
570
+ klass
571
+ end
572
+
573
+ let(:type) do
574
+ oid = Torque::PostgreSQL::Adapter::OID::Composite.new('address')
575
+ allow(oid).to receive(:klass).and_return(composite_klass)
576
+ oid
577
+ end
578
+
579
+ it 'raises when encrypting an undeclared attribute' do
580
+ klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
581
+ klass.type_name = 'address'
582
+
583
+ expect { klass.encrypts :missing }.to raise_error(ArgumentError, /declared attribute/)
584
+ end
585
+
586
+ it 'stores the column encrypted inside the record' do
587
+ data = type.serialize(composite_klass.new(street: 'secret', number: 1))
588
+
589
+ expect(data.values.first).not_to include('secret')
590
+ expect(data.values.first).to include('"p"')
591
+ expect(data.values.third).to be_eql(1)
592
+ end
593
+
594
+ it 'reads the column back decrypted' do
595
+ data = type.serialize(composite_klass.new(street: 'secret', number: 1))
596
+
597
+ expect(type.deserialize(data).street).to be_eql('secret')
598
+ expect(type.deserialize(data).number).to be_eql(1)
599
+ end
600
+
601
+ it 'round-trips through the record literal' do
602
+ data = type.serialize(composite_klass.new(street: 'secret'))
603
+ literal = connection.quote(data)[1..-2].gsub("''", "'")
604
+
605
+ expect(type.deserialize(literal).street).to be_eql('secret')
606
+ end
607
+
608
+ it 'registers the encrypted columns' do
609
+ expect(composite_klass.encrypted_attributes).to include(:street)
610
+ expect(composite_klass.new(street: 'secret').encrypted_attribute?(:street)).to be_falsey
611
+ end
612
+
613
+ it 'exposes the ciphertext of a column read from the database' do
614
+ data = type.serialize(composite_klass.new(street: 'secret'))
615
+ instance = type.deserialize(data)
616
+
617
+ expect(instance.ciphertext_for(:street)).to include('"p"')
618
+ expect(instance.encrypted_attribute?(:street)).to be_truthy
619
+ end
620
+
621
+ it 'supports deterministic encryption' do
622
+ klass = Class.new(Torque::PostgreSQL::Attributes::Composite)
623
+ klass.type_name = 'address'
624
+ klass.encrypts :street, deterministic: true
625
+
626
+ other = Torque::PostgreSQL::Adapter::OID::Composite.new('address')
627
+ allow(other).to receive(:klass).and_return(klass)
628
+
629
+ one = other.serialize(klass.new(street: 'same')).values.first
630
+ two = other.serialize(klass.new(street: 'same')).values.first
631
+ expect(one).to be_eql(two)
632
+ end
633
+ end
634
+
635
+ context 'on where clauses' do
636
+ let(:address) { Composite::Address.new(street: 'Main', number: 9) }
637
+ let!(:place) { Place.create!(name: 'Find', home: { street: 'Main', number: 9 }) }
638
+ let!(:many) { Place.create!(name: 'Many', offices: [{ street: 'A' }, { street: 'B' }]) }
639
+
640
+ it 'casts whole values to the type they belong to' do
641
+ expect(Place.where(home: address).to_sql)
642
+ .to include(%{"places"."home" = '("Main",,"9",)'::address})
643
+
644
+ expect(Place.where(home: place.reload.home).first).to be_eql(place)
645
+ end
646
+
647
+ it 'breaks a hash into conditions over each column' do
648
+ expect(Place.where(home: { street: 'Main' }).to_sql)
649
+ .to include(%{(("places"."home")."street" = 'Main')})
650
+
651
+ expect(Place.where(home: { street: 'Main', number: 9 }).first).to be_eql(place)
652
+ expect(Place.where(home: { street: 'Other' }).first).to be_nil
653
+ end
654
+
655
+ it 'hands each column back to the predicate builder' do
656
+ expect(Place.where(home: { number: 1..15 }).to_sql)
657
+ .to include(%{(("places"."home")."number" BETWEEN 1 AND 15)})
658
+
659
+ expect(Place.where(home: { street: %w[Main Other] }).to_sql)
660
+ .to include(%{(("places"."home")."street" IN ('Main', 'Other'))})
661
+
662
+ expect(Place.where(home: { number: 1..15 }).first).to be_eql(place)
663
+ expect(Place.where(home: { street: %w[Main Other] }).first).to be_eql(place)
664
+ end
665
+
666
+ it 'reaches columns of nested composite types' do
667
+ expect(Place.where(location: { base: { street: 'X' } }).to_sql)
668
+ .to include(%{((("places"."location")."base")."street" = 'X')})
669
+
670
+ nested = Place.create!(name: 'Nested', location: { base: { street: 'X' } })
671
+ expect(Place.where(location: { base: { street: 'X' } }).first).to be_eql(nested)
672
+ end
673
+
674
+ it 'checks if any entry of an array matches a hash' do
675
+ expect(Place.where(offices: { street: 'B' }).to_sql).to include(<<~SQL.squish)
676
+ EXISTS (SELECT 1 FROM UNNEST("places"."offices") "address"
677
+ WHERE (("address")."street" = 'B'))
678
+ SQL
679
+
680
+ expect(Place.where(offices: { street: 'B' }).first).to be_eql(many)
681
+ expect(Place.where(offices: { street: 'C' }).first).to be_nil
682
+ end
683
+
684
+ it 'compares whole values against the entries of an array' do
685
+ expect(Place.where(offices: address).to_sql)
686
+ .to include(%{'("Main",,"9",)'::address = ANY("places"."offices")})
687
+
688
+ expect(Place.where(offices: [address]).to_sql)
689
+ .to include(%{"places"."offices" && '{"(\\"Main\\",,\\"9\\",)"}'::address[]})
690
+
691
+ expect(Place.where(offices: many.reload.offices.first).first).to be_eql(many)
692
+ expect(Place.where(offices: many.offices).first).to be_eql(many)
693
+ end
694
+
695
+ it 'lists whole values on a plain column' do
696
+ other = Composite::Address.new(street: 'Side', number: 1)
697
+
698
+ expect(Place.where(home: [address, other]).to_sql)
699
+ .to include(%{"places"."home" IN ('("Main",,"9",)'::address, '("Side",,"1",)'::address)})
700
+ expect(Place.where(home: [address, other]).first).to be_eql(place)
701
+ expect(Place.where(home: [other]).first).to be_nil
702
+ end
703
+
704
+ it 'loads the columns without a permanent connection' do
705
+ expect(ActiveRecord::Base).not_to receive(:connection)
706
+
707
+ Composite::Address.reset_columns!
708
+ expect(Composite::Address.new(street: 'Main').street).to be_eql('Main')
709
+ end
710
+
711
+ it 'checks each entry of a list of hashes' do
712
+ expect(Place.where(offices: [{ street: 'A' }, { street: 'B' }]).to_sql)
713
+ .to include('EXISTS', 'OR')
714
+
715
+ expect(Place.where(offices: [{ street: 'B' }]).first).to be_eql(many)
716
+ expect(Place.where(offices: [{ street: 'C' }]).first).to be_nil
717
+ end
718
+
719
+ it 'raises when a key is not a column of the composite type' do
720
+ expect { Place.where(home: { nope: 1 }).to_sql }
721
+ .to raise_error(ArgumentError, /not a column of the "address"/)
722
+
723
+ expect { Place.where(home: { nope: { deep: 1 } }).to_sql }
724
+ .to raise_error(ArgumentError, /not a column of the "address"/)
725
+ end
726
+ end
727
+
728
+ context 'on ordering and grouping' do
729
+ let!(:first) { Place.create!(name: 'a', home: { street: 'Main', number: 9 }) }
730
+ let!(:second) { Place.create!(name: 'b', home: { street: 'Side', number: 1 }) }
731
+
732
+ it 'orders by a column' do
733
+ expect(Place.order(home: { street: :desc }).to_sql)
734
+ .to include(%{ORDER BY ("places"."home")."street" DESC})
735
+
736
+ expect(Place.order(home: { street: :desc }).pluck(:name)).to be_eql(%w[b a])
737
+ expect(Place.order(home: { number: :asc }).pluck(:name)).to be_eql(%w[b a])
738
+ end
739
+
740
+ it 'orders by the whole value' do
741
+ expect(Place.order(:home).to_sql).to include(%{ORDER BY "places"."home" ASC})
742
+ expect(Place.order(:home).pluck(:name)).to be_eql(%w[a b])
743
+ end
744
+
745
+ it 'groups by a column' do
746
+ expect(Place.group(home: :street).to_sql).to include(%{GROUP BY ("places"."home")."street"})
747
+ expect(Place.group(home: :street).count).to be_eql('Main' => 1, 'Side' => 1)
748
+ end
749
+
750
+ it 'filters groups with having' do
751
+ expect(Place.group(:home).having(home: { street: 'Main' }).to_sql)
752
+ .to include(%{HAVING (("places"."home")."street" = 'Main')})
753
+
754
+ expect(Place.group(home: :street).having(home: { street: 'Main' }).count)
755
+ .to be_eql('Main' => 1)
756
+ end
757
+
758
+ it 'plucks a column' do
759
+ expect(Place.order(:name).pluck(home: :number)).to be_eql([9, 1])
760
+ end
761
+
762
+ it 'raises when a key is not a column of the composite type' do
763
+ expect { Place.order(home: { nope: :asc }).to_sql }
764
+ .to raise_error(ArgumentError, /not a column of the "address"/)
765
+ end
766
+ end
767
+
768
+ context 'on validation' do
769
+ let(:validator) { Torque::PostgreSQL::Validations::NestedValidator }
770
+
771
+ let(:invalid_address) do
772
+ Composite::Address.new(street: 'Main').tap do |address|
773
+ allow(address).to receive(:invalid?).and_return(true)
774
+ end
775
+ end
776
+
777
+ it 'invalidates the record that holds an invalid composite value' do
778
+ place = Place.new(name: 'Broken', home: invalid_address)
779
+
780
+ expect(place).to be_invalid
781
+ expect(place.errors[:home]).to be_present
782
+ end
783
+
784
+ it 'invalidates the record when an entry of an array is invalid' do
785
+ place = Place.new(name: 'Broken', offices: [invalid_address])
786
+
787
+ expect(place).to be_invalid
788
+ expect(place.errors[:offices]).to be_present
789
+ end
790
+
791
+ it 'keeps records with valid composite values valid' do
792
+ expect(Place.new(name: 'Fine', home: { street: 'Main' })).to be_valid
793
+ end
794
+
795
+ it 'validates the attributes that are also normalized' do
796
+ klass = Class.new(Place) do
797
+ normalizes :home, with: ->(value) { value }
798
+ end
799
+
800
+ place = klass.new(name: 'Broken', home: invalid_address)
801
+
802
+ expect(place).to be_invalid
803
+ expect(place.errors.attribute_names).to include(:home)
804
+ end
805
+
806
+ it 'only validates the attributes backed by a composite type' do
807
+ Place.new
808
+
809
+ attributes = Place.validators.grep(validator).flat_map(&:attributes)
810
+ expect(attributes).to be_eql(%w[home offices location])
811
+ end
812
+
813
+ it 'leaves models without composite columns alone' do
814
+ Author.new
815
+ Comment.new
816
+
817
+ expect(Author.validators).to be_none(validator)
818
+ expect(Comment.validators).to be_none(validator)
819
+ end
820
+
821
+ it 'does not add the validation again when the schema is reloaded' do
822
+ Place.reset_column_information
823
+ Place.new
824
+
825
+ attributes = Place.validators.grep(validator).flat_map(&:attributes)
826
+ expect(attributes).to be_eql(%w[home offices location])
827
+ end
828
+ end
829
+
830
+ context 'on schema' do
831
+ let(:dump_result) do
832
+ ActiveRecord::SchemaDumper.dump(source, (dump_result = StringIO.new))
833
+ dump_result.string
834
+ end
835
+
836
+ it 'dumps composite types after enums' do
837
+ enum_pos = dump_result.index('create_enum "types"')
838
+ type_pos = dump_result.index('create_composite_type "address", force: :cascade do |t|')
839
+ table_pos = dump_result.index('create_table')
840
+
841
+ expect(enum_pos).to be < type_pos
842
+ expect(type_pos).to be < table_pos
843
+ end
844
+
845
+ it 'dumps composite types sorted by dependencies' do
846
+ address_pos = dump_result.index('create_composite_type "address", force: :cascade do |t|')
847
+ full_pos = dump_result.index('create_composite_type "full_address", force: :cascade do |t|')
848
+
849
+ expect(address_pos).to be < full_pos
850
+ end
851
+
852
+ it 'dumps the columns of composite types' do
853
+ expect(dump_result).to include('t.enum "category", enum_type: "types"')
854
+ expect(dump_result).to include('t.composite "base", composite_type: "address"')
855
+ end
856
+
857
+ it 'does not dump table row types' do
858
+ expect(dump_result).not_to match(/create_composite_type "(places|users|authors)"/)
859
+ end
860
+
861
+ it 'dumps composite columns on tables' do
862
+ expect(dump_result).to include('t.composite "home", composite_type: "address"')
863
+ expect(dump_result).to match(/t\.composite "offices", (?:composite_type: "address", array: true|array: true, composite_type: "address")/)
864
+ expect(dump_result).to include('t.composite "location", composite_type: "full_address"')
865
+ end
866
+ end
867
+ end