Skip to content

Implement support for automatically mapping structs #71

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ These are the currently available features:
* [Line](https://github.com/crashtech/torque-postgresql/wiki/Line)
* [Segment](https://github.com/crashtech/torque-postgresql/wiki/Segment)

## Structs

If you are using `create type X as (field_name Y, other_field_name Z)`, torque-postgresql will
automatically map a subclass of ::Torque::Struct to that type using the singular-form ActiveRecord
table naming rules.

EG if you have a type named my_struct, columns of that struct in your app
will be automatically mapped to instances of `class MyStruct < Torque::Struct`, if it is defined.

Nesting is supported; (eg structs can have fields that are themselves structs/arrays of structs).

## Querying

* [Arel](https://github.com/crashtech/torque-postgresql/wiki/Arel)
Expand Down Expand Up @@ -83,3 +94,4 @@ Finally, fix and send a pull request.
## License

Copyright © 2017- Carlos Silva. See [The MIT License](MIT-LICENSE) for further details.

2 changes: 2 additions & 0 deletions lib/torque/postgresql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@
require 'torque/postgresql/reflection'
require 'torque/postgresql/schema_cache'

require 'torque/struct'

require 'torque/postgresql/railtie' if defined?(Rails)
41 changes: 38 additions & 3 deletions lib/torque/postgresql/adapter/database_statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ def create_enum(name, *)
load_additional_types([oid])
end

# Given a name and a hash of fieldname->type, creates an enum type.
def create_struct(name, fields)
# TODO: Support macro types like e.g. :timestamp
sql_values = fields.map do |k,v|
"#{k} #{v}"
end.join(", ")
query = <<~SQL
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
WHERE t.typname = '#{name}'
) THEN
CREATE TYPE \"#{name}\" AS (#{sql_values});
END IF;
END
$$;
SQL
exec_query(query)

# Since we've created a new type, type map needs to be rebooted to include
# the new ones, both normal and array one
oid = query_value("SELECT #{quote(name)}::regtype::oid", "SCHEMA").to_i
load_additional_types([oid])
end

# Change some of the types being mapped
def initialize_type_map(m = type_map)
super
Expand Down Expand Up @@ -73,20 +99,28 @@ def torque_load_additional_types(oids = nil)
INNER JOIN pg_type a ON (a.oid = t.typarray)
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND t.typtype IN ( 'e' )
AND t.typtype IN ( 'e', 'c' )
#{filter}
AND NOT EXISTS(
SELECT 1 FROM pg_catalog.pg_type el
WHERE el.oid = t.typelem AND el.typarray = t.oid
)
AND (t.typrelid = 0 OR (
SELECT c.relkind = 'c' FROM pg_catalog.pg_class c
SELECT c.relkind IN ('c', 'r') FROM pg_catalog.pg_class c
WHERE c.oid = t.typrelid
))
SQL

execute_and_clear(query, 'SCHEMA', []) do |records|
records.each { |row| OID::Enum.create(row, type_map) }
records.each do |row|
if row['typtype'] == 'e'
OID::Enum.create(row, type_map)
elsif row['typtype'] == 'c'
OID::Struct.create(self, row, type_map)
else
raise "Invalid typetyp #{row['typtype'].inspect}, expected e (enum) or c (struct); #{row.inspect}"
end
end
end
end

Expand All @@ -101,6 +135,7 @@ def user_defined_types(*categories)
SELECT t.typname AS name,
CASE t.typtype
WHEN 'e' THEN 'enum'
WHEN 'c' THEN 'struct'
END AS type
FROM pg_type t
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
Expand Down
1 change: 1 addition & 0 deletions lib/torque/postgresql/adapter/oid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative 'oid/line'
require_relative 'oid/range'
require_relative 'oid/segment'
require_relative 'oid/struct'

module Torque
module PostgreSQL
Expand Down
195 changes: 195 additions & 0 deletions lib/torque/postgresql/adapter/oid/struct.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@

# frozen_string_literal: true

module Torque
module PostgreSQL
module Adapter
module OID
class Struct < ActiveModel::Type::Value
attr_reader :name
include ActiveRecord::ConnectionAdapters::Quoting
include ActiveRecord::ConnectionAdapters::PostgreSQL::Quoting

AvailableType = ::Struct.new(:type_map, :name, :oid, :arr_oid, :klass, :array_klass, :registered, keyword_init: true)

def self.for_type(name, klass: nil)
typ = _type_by_name(name)
return typ if !klass

raise "No type registered to #{name}" unless typ
return nil unless typ

if typ.registered
if typ.klass.klass != klass
if defined?(Rails) && !Rails.application.config.cache_classes && typ.klass.klass.name == klass.name
typ.klass.klass = klass # Rails constant reloading
else
raise "Class mismatch; #{name} already registered for #{typ.klass.klass.name}"
end
end
else
typ.klass.klass = klass
typ.type_map.register_type(typ.oid, typ.klass)
typ.type_map.register_type(typ.arr_oid, typ.array_klass)
typ.registered = true
end

typ.name == name ? typ.klass : typ.array_klass
end

def self.register!(type_map, name, oid, arr_oid, klass, array_klass)
raise ArgumentError, "Already Registered" if _type_by_name(name)
available_types << AvailableType.new(
type_map: type_map,
name: name,
oid: oid,
arr_oid: arr_oid,
klass: klass,
array_klass: array_klass,
)
end

def self.available_types
@registry ||= []
end

def self._type_by_name(name)
available_types.find {|a| a.name == name || a.name + '[]' == name}
end

def self.create(connection, row, type_map)
name = row['typname']
return if _type_by_name(name)

oid = row['oid'].to_i
arr_oid = row['typarray'].to_i
type = Struct.new(connection, name)
arr_type = ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(type)

register!(type_map, name, oid, arr_oid, type, arr_type)
end

def initialize(connection, name)
@connection = connection # The connection we're attached to
@name = name

@pg_encoder = PG::TextEncoder::Record.new name: name
@pg_decoder = PG::TextDecoder::Record.new name: name
super()
end

def deserialize(value)
return unless value.present?
return super(value) unless klass
return value if value.is_a? klass
fields = PG::TextDecoder::Record.new.decode(value)
field_names = klass.columns.map(&:name)
attributes = Hash[field_names.zip(fields)]
field_names.each { |field| attributes[field] = klass.type_for_attribute(field).deserialize(attributes[field]) }
build_from_attrs(attributes, from_database: true)
end

def serialize(value)
return if value.blank?
return super(value) unless klass
value = cast_value(value)
if value.nil?
"NULL"
else
casted_values = klass.columns.map do |col|
col_value = value[col.name]
serialized = klass.type_for_attribute(col.name).serialize(col_value)
begin
@connection.type_cast(serialized)
rescue TypeError => e
if klass.type_for_attribute(col.name).class == ActiveModel::Type::Value
# attribute :nested, NestedStruct.database_type
col = klass.columns.find {|c| c.name == col.name }

available_custom_type = self.class._type_by_name(col.sql_type)
if available_custom_type && !available_custom_type.registered
hint = "add `attribute :#{col.name}, #{col.sql_type.classify}.database_#{col.array ? 'array_' : ''}type`"
raise e, "#{e} (in #{klass.name}, #{hint}`", $!.backtrace
end
raise
else
raise
end
end
end
PG::TextEncoder::Record.new.encode(casted_values)
end
end

def assert_valid_value(value)
cast_value(value)
end

def type_cast_for_schema(value)
# TODO: Check default values for struct types work
serialize(value)
end

def ==(other)
self.class == other.class &&
other.klass == klass &&
other.type == type
end

def klass=(value)
raise ArgumentError, "Not a valid struct class" unless validate_klass(value)
@klass = value
end

def klass
@klass ||= validate_klass(name.to_s.camelize.singularize) || validate_klass(name.to_s.camelize.pluralize)
return nil unless @klass
if @klass.ancestors.include?(::ActiveRecord::Base)
return @klass if @klass.table_name == name
end
return nil unless @klass.ancestors.include?(::Torque::Struct)
@klass
end

def type_cast(value)
value
end

private

def validate_klass_name(class_name)
validate_klass class_name.safe_constantize
end

def validate_klass(klass)
if klass && klass.ancestors.include?(::Torque::Struct)
klass
elsif klass && klass.ancestors.include?(::ActiveRecord::Base)
klass.table_name == name ? klass : nil
else
false
end
end

def cast_value(value)
return if value.blank?
return if klass.blank?
return value if value.is_a?(klass)
build_from_attrs(value, from_database: false)
end

def build_from_attrs(attributes, from_database:)
klass.define_attribute_methods
if from_database
attributes = klass.attributes_builder.build_from_database(attributes, {})
klass.allocate.init_with_attributes(attributes)
else
klass.new(attributes)
end
end

end
end
end
end
end
2 changes: 2 additions & 0 deletions lib/torque/postgresql/inheritance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ def physically_inherited?
).present?
rescue ActiveRecord::ConnectionNotEstablished
false
rescue ActiveRecord::NoDatabaseError
false
end

# Get the list of all tables directly or indirectly dependent of the
Expand Down
8 changes: 8 additions & 0 deletions lib/torque/postgresql/migration/command_recorder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ def invert_create_enum(args)
[:drop_type, [args.first]]
end

# Records the creation of the struct to be reverted.
def create_struct(*args, &block)
record(:create_struct, args, &block)
end
def invert_create_struct(*args)
[:drop_type, [args.first]]
end

end

ActiveRecord::Migration::CommandRecorder.include CommandRecorder
Expand Down
Loading