-
-
Notifications
You must be signed in to change notification settings - Fork 16
Composite
Composite types manager. It allows any user-defined composite type to be used as a column, so that its contents are declared by the database, and then cast, tracked, validated, and queried as regular attributes of an ActiveModel class. The type is the single source of truth, which means the class describing it is optional, and the columns of the type become the attributes of the class. PostgreSQL Docs
A composite type is a named row type, and it is made of columns, exactly like a table. The gem loads those types from the database, backs each of them with a class, and then any column using one of them returns an instance of that class.
Composite types are created with create_composite_type, which uses the very same DSL as create_table, although limited to what a type supports.
create_composite_type :address do |t|
t.string 'street'
t.string 'city'
t.integer 'number'
t.enum 'category', enum_type: :types
end
create_composite_type :full_address do |t|
t.composite 'base', composite_type: :address # Types can be nested
t.string 'country'
endOnly the options that describe the type of a column are accepted, which are limit, precision, scale, array, enum_type, and composite_type. Anything else, like null or default, raises an ArgumentError, and so do indexes, because a type cannot hold either.
Existing types are changed with change_composite_type, which mirrors change_table.
change_composite_type :address do |t|
t.string 'zipcode' # ADD ATTRIBUTE
t.change 'number', :bigint # ALTER ATTRIBUTE
t.remove 'city' # DROP ATTRIBUTE
t.rename 'street', 'road' # RENAME ATTRIBUTE
endJust like change_table, it is not reversible. Use the single-column methods for migrations that have to be, where only remove_composite_column needs the type to be informed so that it can be inverted.
add_composite_column :address, 'zipcode', :string
remove_composite_column :address, 'zipcode', :string
change_composite_column :address, 'number', :bigint # Not reversible
rename_composite_column :address, 'street', 'road'Types are dropped with drop_type, and create_composite_type accepts force: :cascade to recreate one.
drop_type :address, force: :cascade
create_composite_type :address, force: :cascade do |t|
# ...
endNote All of these accept a
schemaoption, and the schema dump writes the types before the tables that use them, always sorted so that a type comes after the ones it depends on.
Once a type exists, any column can use it. Both the helper and the plain type name work.
create_table :places do |t|
t.string :name
t.composite :home, composite_type: :address # A single value
t.composite :offices, composite_type: :address, array: true # A native array of values
t.composite :location, composite_type: :full_address # A nested type
t.column :other, :address # The type name works as well
endThere is nothing to declare. Classes are created on demand under the namespace defined by [composite.namespace]({{ site.baseurl }}/getting-started/configuring/#composite.namespace), and their attributes come from the columns of the type.
Composite::Address # Created on demand
Composite::Address.type_name # 'address'
Composite::Address.columns.keys # ['street', 'city', 'number', 'category']
Composite::Address.attribute_names # ['street', 'city', 'number', 'category']Write the class yourself when you want to add behavior to it. It inherits from Torque::PostgreSQL::Composite, and validations are available as any other ActiveModel class.
# models/composite/address.rb
class Composite::Address < Torque::PostgreSQL::Composite
validates :street, presence: true
def to_s
[street, number, city].compact.join(', ')
end
endColumns that the class declares on its own are respected, and only the ones it does not declare are loaded from the type.
class Composite::Address < Torque::PostgreSQL::Composite
attribute :number, :string # Kept as a String, even though the type says integer
endUse [composite.irregular_types]({{ site.baseurl }}/getting-started/configuring/#composite.irregular_types) when the name of the class does not match the name of the type.
c.composite.irregular_types = { 'address' => 'Places::Location' }The column simply returns an instance of the class, and a Hash, an Array of values, or an instance can be assigned to it.
place = Place.new
place.home = { street: 'Main', number: 9 } # A Hash is cast into an instance
place.home = Composite::Address.new(street: 'Main')
place.home = ['Main', 'Springfield', 9, 'A'] # Positional, in the order of the columns
place.home.street # 'Main'
place.home.number # 9, cast by the type of the column
place.home.to_h # {street: 'Main', city: nil, ...}Nested types and arrays of values behave the same way, and every entry is cast.
place.location = { base: { street: 'Deep' }, country: 'BR' }
place.location.base # An instance of Composite::Address
place.offices = [{ street: 'A' }, Composite::Address.new(street: 'B')]
place.offices.first.street # 'A'A column that is NULL reads back as nil, and it stays NULL until something is assigned to it.
place = Place.create!(name: 'HQ')
place.home # nil
place.home.blank? # true
# home IS NULLA composite always carries every one of its columns, so a value with nothing set is a row of nulls, which PostgreSQL keeps apart from a null column. That is why an instance is never blank.
place.home = {}
place.save!
# home => (,,,)
place.home.blank? # false, it is a valueWhole values are compared as records, which keeps them able to use an index.
Place.where(home: address)
# WHERE "places"."home" = '("Main",,"9",)'::addressA Hash is broken down into conditions over each column, and every pair goes back through the predicate builder, so the whole where vocabulary is available inside a composite.
Place.where(home: { street: 'Main' })
# WHERE ("places"."home")."street" = 'Main'
Place.where(home: { number: 1..5 })
# WHERE ("places"."home")."number" BETWEEN 1 AND 5
Place.where(home: { street: %w[A B] })
# WHERE ("places"."home")."street" IN ('A', 'B')
Place.where(location: { base: { street: 'X' } })
# WHERE (("places"."location")."base")."street" = 'X'For array columns, a whole value checks whether any entry matches it, while a Hash checks whether any entry matches the columns it describes.
Place.where(offices: address)
# WHERE '("Main",,"9",)'::address = ANY("places"."offices")
Place.where(offices: [address, other])
# WHERE "places"."offices" && '{"(...)","(...)"}'::address[]
Place.where(offices: { street: 'Main' })
# WHERE EXISTS (
# SELECT 1 FROM UNNEST("places"."offices") "address"
# WHERE ("address")."street" = 'Main'
# )Note A key that is not a column of the type raises an
ArgumentError, instead of being silently ignored.
Validations declared on the composite class run whenever the record is validated. A single :invalid error is added to the column, and every entry is validated for array columns.
place.home.street = nil
place.valid? # false
place.errors.added?(:home, :invalid) # trueThis is done with the nested validator, which is available for any attribute that holds objects that can validate themselves.
validates :home, nested: true
validates :settings, nested: true, allow_blank: trueIndividual columns can be encrypted, so that their values are stored encrypted inside the record, while the rest of it remains readable. This is Active Record's own encrypts, so all of its options are available.
class Composite::Address < Torque::PostgreSQL::Composite
encrypts :street
encrypts :city, deterministic: true # Same content produces the same ciphertext
end
# home => ("{""p"":""5nQ=="",""h"":{...}}",,9,)
place.home.street # 'Main'
place.home.ciphertext_for(:street) # The stored ciphertextNote Encrypting a column that the type does not have raises an
ArgumentError.
Composite classes are ActiveModel classes with the parts of Active Record that make sense off a table, so several of the macros you already use are available inside them.
enum works as it does on a model, on top of the type that came from the database, except that everything which needs a relation or a persisted record is left out. Only the predicates are generated, so there are no alpha! bang methods and no scopes.
class Composite::Address < Torque::PostgreSQL::Composite
enum :category, { residential: 'A', commercial: 'B' }
end
place.home.category # 'residential'
place.home.residential? # true
Composite::Address.categories # {"residential" => "A", "commercial" => "B"}normalizes, store_accessor, validations, callbacks, and as_json / to_json are available as well, and serialization only ever exposes the columns.
place.home.as_json # {"street" => "Main", "city" => nil, ...}Note Anything that depends on a record being saved is out of reach, which means enum scopes,
value!methods, and thesaved_change_to_*family ofstore_accessorare not defined.
Everything is tracked as any other attribute of the record, and reading a value never marks a record as changed.
place.home = { street: 'Other' }
place.changed? # trueThe instance exposes its own changes as well.
place.home.street_changed? # true
place.home.changes # {'street' => ['Main', 'Other']}Can't find what you're looking for? Add an issue to the issue tracker.