torque-postgresql 4.0.0 → 4.1.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +24 -0
  3. data/lib/torque/postgresql/adapter/database_statements.rb +89 -7
  4. data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
  5. data/lib/torque/postgresql/adapter/oid/array.rb +17 -0
  6. data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
  7. data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
  8. data/lib/torque/postgresql/adapter/oid/composite.rb +116 -0
  9. data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
  10. data/lib/torque/postgresql/adapter/oid/lquery.rb +53 -0
  11. data/lib/torque/postgresql/adapter/oid/ltree.rb +53 -0
  12. data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
  13. data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
  14. data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
  15. data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
  16. data/lib/torque/postgresql/adapter/quoting.rb +11 -2
  17. data/lib/torque/postgresql/adapter/schema_definitions.rb +46 -0
  18. data/lib/torque/postgresql/adapter/schema_dumper.rb +28 -2
  19. data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
  20. data/lib/torque/postgresql/adapter.rb +2 -5
  21. data/lib/torque/postgresql/arel/nodes.rb +63 -1
  22. data/lib/torque/postgresql/arel/visitors.rb +19 -8
  23. data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
  24. data/lib/torque/postgresql/associations.rb +3 -0
  25. data/lib/torque/postgresql/attributes/base.rb +94 -0
  26. data/lib/torque/postgresql/attributes/composite.rb +102 -0
  27. data/lib/torque/postgresql/attributes/enum.rb +13 -2
  28. data/lib/torque/postgresql/attributes/lquery.rb +180 -0
  29. data/lib/torque/postgresql/attributes/ltree.rb +169 -0
  30. data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
  31. data/lib/torque/postgresql/attributes/struct.rb +137 -0
  32. data/lib/torque/postgresql/base.rb +17 -14
  33. data/lib/torque/postgresql/config.rb +81 -14
  34. data/lib/torque/postgresql/inheritance/expander.rb +66 -0
  35. data/lib/torque/postgresql/inheritance/record.rb +52 -0
  36. data/lib/torque/postgresql/inheritance.rb +138 -65
  37. data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
  38. data/lib/torque/postgresql/predicate_builder/composite_handler.rb +109 -0
  39. data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +44 -0
  40. data/lib/torque/postgresql/predicate_builder/struct_handler.rb +68 -0
  41. data/lib/torque/postgresql/predicate_builder.rb +26 -0
  42. data/lib/torque/postgresql/predicate_table.rb +61 -0
  43. data/lib/torque/postgresql/railtie.rb +35 -0
  44. data/lib/torque/postgresql/relation/inheritance.rb +146 -28
  45. data/lib/torque/postgresql/relation/join_series.rb +8 -6
  46. data/lib/torque/postgresql/relation/merger.rb +23 -7
  47. data/lib/torque/postgresql/relation.rb +12 -11
  48. data/lib/torque/postgresql/schema_cache.rb +1 -0
  49. data/lib/torque/postgresql/validations.rb +29 -0
  50. data/lib/torque/postgresql/version.rb +1 -1
  51. data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
  52. data/lib/torque/postgresql.rb +6 -0
  53. data/spec/initialize.rb +20 -2
  54. data/spec/mocks/cache_query.rb +12 -0
  55. data/spec/models/author.rb +1 -1
  56. data/spec/models/comment.rb +2 -0
  57. data/spec/models/place.rb +2 -0
  58. data/spec/models/profile.rb +31 -0
  59. data/spec/schema.rb +34 -4
  60. data/spec/tests/arel_spec.rb +5 -3
  61. data/spec/tests/composite_spec.rb +800 -0
  62. data/spec/tests/ltree_spec.rb +552 -0
  63. data/spec/tests/struct_spec.rb +968 -0
  64. data/spec/tests/table_inheritance_spec.rb +1027 -56
  65. metadata +49 -7
@@ -25,6 +25,100 @@ module Torque
25
25
  SQL
26
26
  end
27
27
 
28
+ # Creates a new composite type, with the columns being defined using
29
+ # the same DSL as +create_table+, although limited to the type itself,
30
+ # the size-related options, and no indexes or constraints
31
+ #
32
+ # Example:
33
+ # create_composite_type :address do |t|
34
+ # t.string "street"
35
+ # t.integer "number"
36
+ # end
37
+ def create_composite_type(name, options = {})
38
+ td = create_composite_type_definition(name)
39
+ yield td if block_given?
40
+
41
+ validate_composite_type!(name, td)
42
+ drop_type(name, force: options[:force], check: true, schema: options[:schema]) \
43
+ if options[:force]
44
+
45
+ columns = td.columns.map { |column| composite_column_definition(name, column) }
46
+ type_name = sanitize_name_with_schema(name, options.dup)
47
+
48
+ internal_exec_query(<<~SQL.squish, 'SCHEMA').tap { reload_type_map }
49
+ CREATE TYPE #{quote_type_name(type_name)} AS (#{columns.join(', ')})
50
+ SQL
51
+ end
52
+
53
+ # Changes an existing composite type, using the same DSL as
54
+ # +change_table+, although limited to what a type supports
55
+ #
56
+ # Example:
57
+ # change_composite_type :address do |t|
58
+ # t.string 'zipcode'
59
+ # t.change 'number', :bigint
60
+ # t.remove 'city'
61
+ # t.rename 'street', 'road'
62
+ # end
63
+ def change_composite_type(name, options = {})
64
+ td = create_composite_type_definition(name)
65
+ yield td
66
+
67
+ validate_composite_type!(name, td)
68
+ type_name = quote_type_name(sanitize_name_with_schema(name, options.dup))
69
+ actions = composite_type_actions(name, td)
70
+
71
+ execute("ALTER TYPE #{type_name} #{actions.join(', ')}") if actions.any?
72
+
73
+ td.renames.each do |from, to|
74
+ execute <<~SQL.squish
75
+ ALTER TYPE #{type_name}
76
+ RENAME ATTRIBUTE #{quote_column_name(from)} TO #{quote_column_name(to)}
77
+ SQL
78
+ end
79
+
80
+ reset_composite_type(name)
81
+ end
82
+
83
+ # Adds a single column to an existing composite type
84
+ def add_composite_column(type_name, column_name, type, options = {})
85
+ options = options.dup
86
+ schema = options.delete(:schema)
87
+
88
+ td = create_composite_type_definition(type_name)
89
+ td.column(column_name, type, **options)
90
+
91
+ alter_composite_type(type_name, schema, composite_type_actions(type_name, td))
92
+ end
93
+
94
+ # Removes a single column of an existing composite type. The +type+ is
95
+ # only needed to make the migration reversible
96
+ def remove_composite_column(type_name, column_name, type = nil, options = {})
97
+ action = "DROP ATTRIBUTE #{quote_column_name(column_name)}"
98
+ alter_composite_type(type_name, options[:schema], [action])
99
+ end
100
+
101
+ # Changes the type of a single column of an existing composite type
102
+ def change_composite_column(type_name, column_name, type, options = {})
103
+ options = options.dup
104
+ schema = options.delete(:schema)
105
+
106
+ td = create_composite_type_definition(type_name)
107
+ td.change(column_name, type, **options)
108
+
109
+ alter_composite_type(type_name, schema, composite_type_actions(type_name, td))
110
+ end
111
+
112
+ # Renames a single column of an existing composite type
113
+ def rename_composite_column(type_name, column_name, new_name, options = {})
114
+ action = <<~SQL.squish
115
+ RENAME ATTRIBUTE #{quote_column_name(column_name)}
116
+ TO #{quote_column_name(new_name)}
117
+ SQL
118
+
119
+ alter_composite_type(type_name, options[:schema], [action])
120
+ end
121
+
28
122
  # Creates a column that stores the underlying language of the record so
29
123
  # that a search vector can be created dynamically based on it. It uses
30
124
  # a `regconfig` type, so string conversions are mandatory
@@ -92,6 +186,28 @@ module Torque
92
186
  end
93
187
 
94
188
 
189
+ # Spread the features of the parent tables into the one being created,
190
+ # since PostgreSQL only propagates columns and their check constraints
191
+ def create_table(table_name, **options, &block)
192
+ sync = options.delete(:sync)
193
+ result = super
194
+ return result if sync.blank?
195
+
196
+ parents = Array.wrap(options[:inherits]).flatten.compact.map(&:to_s)
197
+ return result if parents.empty?
198
+
199
+ sync_inheritance_into(table_name.to_s, parents, sync_inheritance_selection(sync), false)
200
+ result
201
+ end
202
+
203
+ # A primary key that came from a parent table is described by the +sync+
204
+ # option while dumping, otherwise the dumper would describe the inherited
205
+ # column all over again and the load would break on the duplicate
206
+ def primary_key(table_name)
207
+ return super unless @_dump_mode
208
+ sync_inheritance_parent_primary_key?(table_name) ? nil : super
209
+ end
210
+
95
211
  # Add the schema option when extracting table options
96
212
  def table_options(table_name)
97
213
  options = super
@@ -108,6 +224,7 @@ module Torque
108
224
 
109
225
  tables = inherited_table_names(table_name)
110
226
  options[:inherits] = tables.one? ? tables.first : tables
227
+ options[:sync] = { primary_key: true } if sync_inheritance_parent_primary_key?(table_name)
111
228
  end
112
229
 
113
230
  options
@@ -137,6 +254,25 @@ module Torque
137
254
  super + [:schema, :inherits]
138
255
  end
139
256
 
257
+ # Add composite_type as one of the valid options for column definition
258
+ def valid_column_definition_options
259
+ super + [:composite_type]
260
+ end
261
+
262
+ # Maps the composite type through its required option, mirroring how
263
+ # enums are handled
264
+ def type_to_sql(type, composite_type: nil, **options)
265
+ return super(type, **options) unless type.to_s == 'composite'
266
+
267
+ raise ArgumentError, <<~MSG.squish if composite_type.nil?
268
+ composite_type is required for composite columns
269
+ MSG
270
+
271
+ sql = composite_type.to_s
272
+ sql = "#{sql}[]" if options[:array]
273
+ sql
274
+ end
275
+
140
276
  # Add proper support for schema load when using versioned commands
141
277
  def assume_migrated_upto_version(version)
142
278
  return super unless PostgreSQL.config.versioned_commands.enabled
@@ -195,6 +331,77 @@ module Torque
195
331
  Quoting::Name.new(schema.to_s, name.to_s)
196
332
  end
197
333
 
334
+ # A composite type is defined with the same DSL as a table, but only
335
+ # its columns are taken into account
336
+ def create_composite_type_definition(name)
337
+ CompositeTypeDefinition.new(self, name)
338
+ end
339
+
340
+ # Runs a single set of changes over an existing composite type
341
+ def alter_composite_type(name, schema, actions)
342
+ type_name = quote_type_name(sanitize_name_with_schema(name, schema: schema))
343
+ execute("ALTER TYPE #{type_name} #{actions.join(', ')}")
344
+ reset_composite_type(name)
345
+ end
346
+
347
+ # Every addition, change, and removal that a composite type can batch
348
+ # into a single statement. Renames are left out because PostgreSQL
349
+ # does not allow them to be combined with other actions
350
+ def composite_type_actions(name, td)
351
+ adds = td.columns.map do |column|
352
+ "ADD ATTRIBUTE #{composite_column_definition(name, column)}"
353
+ end
354
+
355
+ changes = td.changes.map do |column|
356
+ quoted = quote_column_name(column.name)
357
+ "ALTER ATTRIBUTE #{quoted} TYPE #{composite_column_type(name, column)}"
358
+ end
359
+
360
+ drops = td.removals.map { |name| "DROP ATTRIBUTE #{quote_column_name(name)}" }
361
+
362
+ adds + changes + drops
363
+ end
364
+
365
+ # A composite type only holds columns, so anything else that the DSL
366
+ # can produce is rejected
367
+ def validate_composite_type!(name, td)
368
+ raise ArgumentError, <<~MSG.squish if td.indexes.present?
369
+ Unable to define the composite type "#{name}" because it does
370
+ not support indexes.
371
+ MSG
372
+ end
373
+
374
+ # Build a single column of a composite type
375
+ def composite_column_definition(name, column)
376
+ "#{quote_column_name(column.name)} #{composite_column_type(name, column)}"
377
+ end
378
+
379
+ # Resolve the type of a column, making sure that only supported
380
+ # options were provided
381
+ def composite_column_type(name, column)
382
+ supported = %i[limit precision scale array enum_type composite_type]
383
+ options = column.options.dup
384
+ options.delete(:primary_key) unless options[:primary_key]
385
+
386
+ unsupported = options.keys - supported
387
+ raise ArgumentError, <<~MSG.squish if unsupported.present?
388
+ Unable to define the composite type "#{name}" because the
389
+ column "#{column.name}" contains unsupported options:
390
+ #{unsupported.map(&:inspect).join(', ')}.
391
+ MSG
392
+
393
+ type_to_sql(column.type, **options.slice(*supported))
394
+ end
395
+
396
+ # Members loaded by the class that handles the type are no longer
397
+ # valid once the type itself has changed
398
+ def reset_composite_type(name)
399
+ reload_type_map
400
+ return unless PostgreSQL.config.composite.enabled
401
+
402
+ Attributes::Composite.lookup(name.to_s.split('.').last).reset_columns!
403
+ end
404
+
198
405
  def quote_enum_values(name, values, options)
199
406
  prefix = options[:prefix]
200
407
  prefix = name if prefix === true
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'adapter/database_statements'
4
+ require_relative 'adapter/inheritance_statements'
4
5
  require_relative 'adapter/oid'
5
6
  require_relative 'adapter/quoting'
6
7
  require_relative 'adapter/schema_creation'
@@ -13,6 +14,7 @@ module Torque
13
14
  module Adapter
14
15
  include Quoting
15
16
  include DatabaseStatements
17
+ include InheritanceStatements
16
18
  include SchemaStatements
17
19
 
18
20
  # :nodoc:
@@ -31,11 +33,6 @@ module Torque
31
33
  )
32
34
  end
33
35
 
34
- # Add `inherits` and `schema` to the list of extracted table options
35
- def extract_table_options!(options)
36
- super.merge(options.extract!(:inherits, :schema))
37
- end
38
-
39
36
  # Allow filtered bulk insert by adding the where clause. This method is
40
37
  # only used by +InsertAll+, so it somewhat safe to override it
41
38
  def build_insert_sql(insert)
@@ -13,12 +13,74 @@ module Torque
13
13
  include ::Arel::Math
14
14
 
15
15
  def initialize(left, right, array = false)
16
- right = +right.to_s
16
+ right = right.to_s.dup
17
17
  right << '[]' if array
18
18
  super left, right
19
19
  end
20
20
  end
21
21
 
22
+ # Accessing a single column of a composite value, which behaves like an
23
+ # attribute of the composite type it belongs to
24
+ class Column < ::Arel::Nodes::Binary
25
+ include ::Arel::Expressions
26
+ include ::Arel::Predications
27
+ include ::Arel::AliasPredication
28
+ include ::Arel::OrderPredications
29
+ include ::Arel::Math
30
+
31
+ attr_reader :type_caster
32
+ alias name right
33
+
34
+ def initialize(source, name, type_caster)
35
+ @type_caster = type_caster
36
+ super(source, name.to_s)
37
+ end
38
+
39
+ def able_to_type_cast?
40
+ true
41
+ end
42
+
43
+ def type_cast_for_database(value)
44
+ type_caster.serialize(value)
45
+ end
46
+ end
47
+
48
+ # Accessing a single property of a document, at any depth, casted to
49
+ # the type that the class declares for it
50
+ class Property < ::Arel::Nodes::Binary
51
+ include ::Arel::Expressions
52
+ include ::Arel::Predications
53
+ include ::Arel::AliasPredication
54
+ include ::Arel::OrderPredications
55
+ include ::Arel::Math
56
+
57
+ attr_reader :type_caster, :cast
58
+
59
+ alias path right
60
+
61
+ # Reaching a property of another one simply adds up to the path, so
62
+ # that a single node always holds the whole way to it
63
+ def initialize(source, key, type_caster, cast = nil)
64
+ @type_caster = type_caster
65
+ @cast = cast
66
+
67
+ return super(source.left, source.path + [key.to_s]) if source.is_a?(Property)
68
+ super(source, [key.to_s])
69
+ end
70
+
71
+ def name
72
+ right.last
73
+ end
74
+
75
+ def able_to_type_cast?
76
+ true
77
+ end
78
+
79
+ def type_cast_for_database(value)
80
+ type_caster.serialize(value)
81
+ end
82
+ end
83
+
22
84
  class Ref < ::Arel::Nodes::Unary
23
85
  attr_reader :reference
24
86
  alias to_s expr
@@ -16,19 +16,30 @@ module Torque
16
16
  quote_array(o.expr, collector)
17
17
  end
18
18
 
19
- # Allow quoted arrays to get here
20
- def visit_Arel_Nodes_Casted(o, collector)
21
- value = o.value_for_database
22
- klass = ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array::Data
23
- return super unless value.is_a?(klass)
24
- quote_array(value.values, collector)
25
- end
26
-
27
19
  ## TORQUE VISITORS
28
20
  def visit_Torque_PostgreSQL_Arel_Nodes_Ref(o, collector)
29
21
  collector << quote_table_name(o.expr)
30
22
  end
31
23
 
24
+ # Access a single column of a composite value
25
+ def visit_Torque_PostgreSQL_Arel_Nodes_Column(o, collector)
26
+ collector << '('
27
+ visit(o.left, collector)
28
+ collector << ').' << quote_column_name(o.right)
29
+ end
30
+
31
+ # Access a single property of a document, as text, so that it can then
32
+ # be casted to whatever the property is supposed to be
33
+ def visit_Torque_PostgreSQL_Arel_Nodes_Property(o, collector)
34
+ collector << '('
35
+ visit(o.left, collector)
36
+ collector << ' #>> '
37
+ visit(::Arel.array(o.path), collector)
38
+ collector << ')'
39
+ collector << '::' << o.cast if o.cast
40
+ collector
41
+ end
42
+
32
43
  # Allow casting any node
33
44
  def visit_Torque_PostgreSQL_Arel_Nodes_Cast(o, collector)
34
45
  visit(o.left, collector) << '::' << o.right
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Associations
6
+ module JoinDependency
7
+ def apply_column_aliases(relation)
8
+ result = super
9
+ return result unless relation.from_clause.empty?
10
+
11
+ relation._select!(root_record_class_marker) if cast_join_root?(relation)
12
+ relation._select!(-> { joined_record_class_markers })
13
+ result
14
+ end
15
+
16
+ private
17
+
18
+ def cast_join_root?(relation)
19
+ return false if relation.itself_only_value === true
20
+ return false unless join_root_alias
21
+
22
+ join_root.base_klass.physically_inheritances?
23
+ end
24
+
25
+ def root_record_class_marker
26
+ table = join_root.base_klass.arel_table
27
+ table['tableoid'].pg_cast(:regclass).as(record_class_column_name)
28
+ end
29
+
30
+ # The root is projected under its own name and picked up by the
31
+ # column sweep in #instantiate, which only ever feeds the root. Every
32
+ # other join part has to carry the marker inside its own set of
33
+ # aliases, since that is all #extract_record ever reads
34
+ def joined_record_class_markers
35
+ markers = join_root.each_with_index.map do |join_part, index|
36
+ next if index.zero? || !join_part.base_klass.physically_inheritances?
37
+ next if (columns = aliases.column_aliases(join_part)).blank?
38
+
39
+ column_alias = "#{columns.first.alias[/\At\d+/]}_r#{columns.size}"
40
+ columns << ::ActiveRecord::Associations::JoinDependency::Aliases::Column
41
+ .new(record_class_column_name, column_alias)
42
+
43
+ join_part.table['tableoid'].pg_cast(:regclass).as(column_alias)
44
+ end
45
+
46
+ markers.compact
47
+ end
48
+
49
+ def record_class_column_name
50
+ ::ActiveRecord::Relation._record_class_column_name
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -4,7 +4,10 @@ require_relative 'associations/foreign_association'
4
4
 
5
5
  require_relative 'associations/builder'
6
6
  require_relative 'associations/preloader'
7
+ require_relative 'associations/join_dependency'
7
8
 
8
9
  association_mod = Torque::PostgreSQL::Associations::ForeignAssociation
9
10
  ::ActiveRecord::Associations::HasManyAssociation.prepend(association_mod)
10
11
  ::ActiveRecord::Associations::BelongsToManyAssociation.prepend(association_mod)
12
+
13
+ ::ActiveRecord::Associations::JoinDependency.prepend(Torque::PostgreSQL::Associations::JoinDependency)
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'simple_enum'
4
+
5
+ module Torque
6
+ module PostgreSQL
7
+ module Attributes
8
+ # The common ground between the classes that back a column with a set of
9
+ # typed properties, like structs and composite types. It carries enough of
10
+ # the ActiveRecord attribute surface for its own features, and for the
11
+ # ActiveRecord concerns that build on top of it, to work
12
+ class Base
13
+ include ActiveModel::Model
14
+ include ActiveModel::Attributes
15
+ include ActiveModel::Serialization
16
+ include ActiveModel::Serializers::JSON
17
+ include ActiveModel::Dirty
18
+ include ActiveModel::Validations::Callbacks
19
+
20
+ include SimpleEnum
21
+ include ActiveRecord::Store
22
+ # Rails 8.1 moved normalization to Active Model, which is where these
23
+ # classes belong anyway
24
+ include(AR810 ? ActiveModel::Attributes::Normalization : ActiveRecord::Normalization)
25
+ include ActiveRecord::Encryption::EncryptableRecord
26
+
27
+ class << self
28
+ # These classes are not backed by a table, so nothing here has the
29
+ # extra information that a column would provide
30
+ def columns_hash
31
+ {}
32
+ end
33
+
34
+ # Encrypt individual attributes, so their values are stored encrypted
35
+ # inside the column that holds them
36
+ def encrypts(*names, **options)
37
+ names.each do |name|
38
+ raise ArgumentError, <<~MSG.squish unless attribute_names.include?(name.to_s)
39
+ Unable to encrypt "#{name}" because it is not a declared
40
+ attribute of #{self.name}.
41
+ MSG
42
+ end
43
+
44
+ super
45
+ end
46
+ end
47
+
48
+ # Plain access to any property, declared or not
49
+ def [](key)
50
+ key = key.to_s
51
+ @attributes.key?(key) ? @attributes.fetch_value(key) : nil
52
+ end
53
+
54
+ def []=(key, value)
55
+ assign_attributes(key => value)
56
+ end
57
+
58
+ def ==(other)
59
+ other.class == self.class && other.attributes == attributes
60
+ end
61
+ alias eql? ==
62
+
63
+ def empty?
64
+ @attributes.keys.empty?
65
+ end
66
+
67
+ def hash
68
+ [self.class, attributes].hash
69
+ end
70
+
71
+ def to_h
72
+ attributes.symbolize_keys
73
+ end
74
+
75
+ def inspect
76
+ entries = attributes.map { |key, value| "#{key}: #{value.inspect}" }
77
+ "#<#{self.class.name} #{entries.join(', ')}>"
78
+ end
79
+
80
+ def type_for_attribute(name, &block)
81
+ self.class.type_for_attribute(name, &block)
82
+ end
83
+
84
+ def read_attribute_before_type_cast(name)
85
+ @attributes[name.to_s].value_before_type_cast
86
+ end
87
+
88
+ def read_attribute_for_database(name)
89
+ @attributes[name.to_s].value_for_database
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Torque
6
+ module PostgreSQL
7
+ module Attributes
8
+ class Composite < Base
9
+ class << self
10
+ attr_writer :type_name
11
+
12
+ # Find or create the class that will handle the composite type. It
13
+ # first checks the irregular types mapping, then the namespace
14
+ def lookup(name)
15
+ name = name.to_s.underscore
16
+ mapping = PostgreSQL.config.composite.irregular_types
17
+ return resolve(mapping[name].constantize, name) if mapping.key?(name)
18
+
19
+ const = name.camelize
20
+ namespace = PostgreSQL.config.composite.namespace
21
+
22
+ return resolve(namespace.const_get(const), name) if namespace.const_defined?(const)
23
+ resolve(namespace.const_set(const, Class.new(Composite)), name)
24
+ end
25
+
26
+ def inherited(subclass)
27
+ super
28
+ subclass.instance_variable_set(:@load_columns_monitor, ::Monitor.new)
29
+ end
30
+
31
+ # The name of the composite type on the database
32
+ def type_name
33
+ @type_name ||= name.demodulize.underscore
34
+ end
35
+
36
+ # The ordered list of columns of the composite type, as a hash of
37
+ # name and type, loaded in a lazy way
38
+ def columns
39
+ load_columns
40
+ @columns
41
+ end
42
+
43
+ # Load the columns from the database and define any attribute that
44
+ # was not manually declared
45
+ def load_columns
46
+ return if self == Composite || defined?(@columns)
47
+
48
+ @load_columns_monitor.synchronize do
49
+ next if defined?(@columns)
50
+
51
+ columns = connection.composite_column_types(type_name)
52
+ columns.each do |attr_name, attr_type|
53
+ attribute(attr_name, attr_type) unless attribute_names.include?(attr_name)
54
+ end
55
+
56
+ @columns = columns
57
+ end
58
+ end
59
+
60
+ # Drop the loaded columns, so that they are collected again on the
61
+ # next use, which happens after the type is changed
62
+ def reset_columns!
63
+ @load_columns_monitor.synchronize do
64
+ remove_instance_variable(:@columns) if defined?(@columns)
65
+ end
66
+ end
67
+
68
+ def new(*args, **xargs, &block)
69
+ load_columns
70
+ super
71
+ end
72
+
73
+ # Columns have to be in place before any of them can be decorated
74
+ def encrypts(*names, **options)
75
+ load_columns
76
+ super
77
+ end
78
+
79
+ def enum(*args, **xargs)
80
+ load_columns
81
+ super
82
+ end
83
+
84
+ private
85
+
86
+ # Make sure that the class knows the name of the type it handles,
87
+ # since it can be resolved from an irregular mapping
88
+ def resolve(klass, name)
89
+ klass.type_name = name
90
+ klass
91
+ end
92
+
93
+ def connection
94
+ ActiveRecord::Base.connection
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ Composite = Attributes::Composite
101
+ end
102
+ end
@@ -39,11 +39,22 @@ module Torque
39
39
  super
40
40
  end
41
41
 
42
+ def inherited(subclass)
43
+ super
44
+ subclass.instance_variable_set(:@load_values_monitor, ::Monitor.new)
45
+ end
46
+
42
47
  # Load the list of values in a lazy way
43
48
  def values
44
- @values ||= self == Enum ? nil : begin
45
- connection.enum_values(type_name).freeze
49
+ return if self == Enum
50
+ return @values if defined?(@values)
51
+
52
+ @load_values_monitor.synchronize do
53
+ next if defined?(@values)
54
+ @values = connection.enum_values(type_name).freeze
46
55
  end
56
+
57
+ @values
47
58
  end
48
59
 
49
60
  # List of values as symbols