class JsonApiPgSql
Attributes
Public Class Methods
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 375 def self.json_column_type # These classes may not exist, depending on the Rails version: @@json_column_type = if Rails::VERSION::STRING >= '5.2' 'ActiveRecord::Type::Json' else 'ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json' end.constantize end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 384 def initialize(base_serializer, base_relation, instance_options, options) @base_relation = base_relation @instance_options = instance_options @options = options # Make a JsonThing for everything, # cached as the full_name: # Watch out: User.where is a Relation, but plain User is not: ar_class = ActiveRecord::Relation === base_relation ? base_relation.klass : base_relation case base_serializer when ActiveModel::Serializer::CollectionSerializer ActiveModelSerializers::Adapter::JsonApiPg.warn_about_collection_serializer base_serializer = base_serializer.element_serializer @many = true when ActiveModelSerializersPg::CollectionSerializer base_serializer = base_serializer.element_serializer @many = true else base_serializer = base_serializer.class @many = false end base_serializer ||= ActiveModel::Serializer.serializer_for(ar_class.new, options) @base_serializer = base_serializer base_name = ar_class.name.underscore.pluralize base_thing = JsonThing.new(ar_class, base_name, base_serializer, options) @fields_for = {} @attribute_fields_for = {} @reflection_fields_for = {} @json_things = { base: base_thing, # `base` is a sym but every other key is a string } @json_things[base_name] = base_thing # We don't need to add anything else to @json_things yet # because we'll lazy-build it via get_json_thing. # That lets us go as deep in the relationships as we need # without loading anything extra. end
Public Instance Methods
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 779 def _attribute_fields_for(resource) attrs = Set.new(serializer_attributes(resource)) # JSON:API always excludes the `id` # even if it's part of the serializer: attrs = attrs - [resource.primary_key.to_sym] fields_for(resource).select { |f| attrs.include? f }.to_a end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 759 def _fields_for(resource) # Sometimes options[:fields] has plural keys and sometimes singular, # so try both: resource_key = resource.json_type.to_sym fields = @instance_options.dig :fields, resource_key if fields.nil? resource_key = resource.json_type.singularize.to_sym fields = @instance_options.dig :fields, resource_key end if fields.nil? # If the user didn't request specific fields, then give them all that appear in the serializer: fields = serializer_attributes(resource).to_a + serializer_reflections(resource).to_a end fields end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 791 def _reflection_fields_for(resource) refls = Set.new(serializer_reflections(resource)) fields_for(resource).select { |f| refls.include? f }.to_a end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 775 def attribute_fields_for(resource) @attribute_fields_for[resource.full_name] ||= _attribute_fields_for(resource) end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 704 def base_resource @json_things[:base] end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 498 def column_is_castable_to_jsonb?(column_class) column_class.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Hstore) or column_class.is_a?(self.class.json_column_type) end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 503 def column_is_castable_to_jsonb_array?(column_class) column_class.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array) and column_is_castable_to_jsonb?(column_class.subtype) end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 489 def column_is_jsonb?(column_class) column_class.is_a? ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 493 def column_is_jsonb_array?(column_class) column_class.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array) and column_class.subtype.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Jsonb) end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 755 def fields_for(resource) @fields_for[resource.full_name] ||= _fields_for(resource) end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 425 def get_json_thing(resource, field) refl_name = "#{resource.full_name}.#{field}" @json_things[refl_name] ||= resource.from_reflection(field) end
Takes a dotted field name (not including the base resource) like we might find in options, and builds up all the JsonThings needed to get to the end.
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 672 def get_json_thing_from_base(field) r = base_resource field.split('.').each do |f| r = get_json_thing(r, f) end r end
See note in _jbs_name method for why we split each thing into two CTEs.
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 640 def include_cte(resource) parent = resource.parent <<~EOQ SELECT DISTINCT ON ("#{resource.table_name}"."#{resource.primary_key}") "#{resource.table_name}".* FROM "#{resource.table_name}" JOIN "#{parent.cte_name}" ON #{include_cte_join_condition(resource)} ORDER BY "#{resource.table_name}"."#{resource.primary_key}" EOQ end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 628 def include_cte_join_condition(resource) parent = resource.parent if resource.belongs_to? %Q{"#{parent.cte_name}"."#{resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}"} elsif resource.has_many? or resource.has_one? %Q{"#{parent.cte_name}"."#{parent.primary_key}" = "#{resource.table_name}"."#{resource.foreign_key}"} else raise "not supported relationship: #{resource.full_name}" end end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 680 def include_ctes includes.map { |inc| # Be careful: inc might have dots: th = get_json_thing_from_base(inc) <<~EOQ "#{th.cte_name}" AS ( #{include_cte(th)} ), EOQ }.join("\n") end
See note in _jbs_name method for why we split each thing into two CTEs.
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 653 def include_jbs(resource) <<~EOQ SELECT "#{resource.table_name}".*, #{select_resource(resource)} AS j FROM "#{resource.cte_name}" AS "#{resource.table_name}" #{join_resource_relationships(resource)} EOQ end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 692 def include_jbsses includes.map { |inc| # Be careful: inc might have dots: th = get_json_thing_from_base(inc) <<~EOQ "#{th.jbs_name}" AS ( #{include_jbs(th)} ), EOQ }.join("\n") end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 616 def include_selects @include_selects ||= includes.map {|inc| th = get_json_thing_from_base(inc) # TODO: UNION ALL would be faster than UNION, # but then we still need to de-dupe when we have two paths to the same table, # e.g. buyer and seller for User. # But we could group those and union just them, or even better do a DISTINCT ON (id). # Since we don't get the id here that could be another CTE. %Q{UNION SELECT j FROM "#{th.jbs_name}"} } end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 662 def includes @includes ||= (@instance_options[:include] || []).sort_by do |inc| # Sort these by length so we never have bad foreign references in the CTEs: inc.size end end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 551 def join_resource_relationships(resource) fields = reflection_fields_for(resource) fields.map{|f| child_resource = get_json_thing(resource, f) refl = child_resource.reflection if refl.has_many? if refl.ar_reflection.present? # Preserve ordering options, either from the AR association itself # or from the class's default scope. # TODO: preserve the whole custom relation, not just ordering p = refl.ar_class.new ordering = p.send(refl.name).arel.orders ordering = child_resource.ar_class.default_scoped.arel.orders if ordering.empty? ordering = ordering.map{|o| case o # TODO: The gsub is pretty awful.... when Arel::Nodes::Ordering o.to_sql.gsub("\"#{child_resource.table_name}\"", "rel") when String o else raise "Unknown type of ordering: #{o.inspect}" end }.join(', ').presence ordering = "ORDER BY #{ordering}" if ordering <<~EOQ LEFT OUTER JOIN LATERAL ( SELECT coalesce(jsonb_agg(jsonb_build_object('id', rel."#{child_resource.primary_key_attr}"::text, 'type', '#{child_resource.json_type}') #{ordering}), '[]') AS j FROM "#{child_resource.table_name}" rel WHERE rel."#{child_resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}" ) "rel_#{child_resource.cte_name}" ON true EOQ elsif not refl.reflection_sql.nil? # can't use .present? since that loads the Relation! case refl.reflection_sql when String raise "TODO" when ActiveRecord::Relation rel = refl.reflection_sql sql = rel.select(<<~EOQ).to_sql coalesce(jsonb_agg(jsonb_build_object('id', "#{child_resource.table_name}"."#{child_resource.primary_key_attr}"::text, 'type', '#{child_resource.json_type}')), '[]') AS j EOQ <<~EOQ LEFT OUTER JOIN LATERAL ( #{sql} ) "rel_#{child_resource.cte_name}" ON true EOQ end end elsif refl.has_one? <<~EOQ LEFT OUTER JOIN LATERAL ( SELECT jsonb_build_object('id', rel."#{child_resource.primary_key_attr}"::text, 'type', '#{child_resource.json_type}') AS j FROM "#{child_resource.table_name}" rel WHERE rel."#{child_resource.foreign_key}" = "#{resource.table_name}"."#{resource.primary_key}" ) "rel_#{child_resource.cte_name}" ON true EOQ else nil end }.compact.join("\n") end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 434 def json_key(name) JsonThing.json_key(name) end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 430 def many? @many end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 708 def maybe_select_resource_relationships(resource) rels_sql = select_resource_relationships(resource) if rels_sql.nil? '' else %Q{, 'relationships', #{rels_sql}} end end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 787 def reflection_fields_for(resource) @reflection_fields_for[resource.full_name] ||= _reflection_fields_for(resource) end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 717 def select_resource(resource) fields = fields_for(resource) <<~EOQ jsonb_build_object('id', "#{resource.table_name}"."#{resource.primary_key_attr}"::text, 'type', '#{resource.json_type}', 'attributes', #{select_resource_attributes(resource)} #{maybe_select_resource_relationships(resource)}) EOQ end
Returns SQL for one JSON value for the resource's 'attributes' object. If a field is an enum then we convert it from an int to a string. If a field has a #{field}__sql method on the ActiveRecord
class, we use that instead.
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 451 def select_resource_attribute(resource, field) typ = resource.ar_class.attribute_types[field.to_s] if typ.is_a? ActiveRecord::Enum::EnumType <<~EOQ CASE #{typ.as_json['mapping'].map{|str, int| %Q{WHEN "#{resource.table_name}"."#{field}" = #{int} THEN '#{str}'}}.join("\n ")} END EOQ elsif resource.has_sql_method?(field) resource.sql_method(field) else field = resource.unaliased(field) # Standard AMS dasherizes json/jsonb/hstore columns, # so we have to do the same: if ActiveModelSerializers.config.key_transform == :dash cl = resource.ar_class.attribute_types[field.to_s] if column_is_jsonb? cl %Q{jsonb_dasherize("#{resource.table_name}"."#{field}")} elsif column_is_jsonb_array? cl # TODO: Could be faster: # If we made the jsonb_dasherize function smarter so it could handle jsonb[], # we wouldn't have to build a json object from the array then cast to jsonb[]. %Q{jsonb_dasherize(array_to_json("#{resource.table_name}"."#{field}")::jsonb)} elsif column_is_castable_to_jsonb? cl # Fortunately we can cast hstore to jsonb, # which gives us a solution that works whether or not the hstore extension is installed. # Defining an hstore_dasherize function would work only if the extension were present. %Q{jsonb_dasherize("#{resource.table_name}"."#{field}"::jsonb)} elsif column_is_castable_to_jsonb_array? cl # TODO: Could be faster (see above): %Q{jsonb_dasherize(array_to_json("#{resource.table_name}"."#{field}"::jsonb[])::jsonb)} else %Q{"#{resource.table_name}"."#{field}"} end else %Q{"#{resource.table_name}"."#{field}"} end end end
Given a JsonThing
and the fields you want, outputs the json column for a SQL SELECT clause.
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 440 def select_resource_attributes(resource) fields = attribute_fields_for(resource) <<~EOQ jsonb_build_object(#{fields.map{|f| "'#{json_key(f)}', #{select_resource_attribute(resource, f)}"}.join(', ')}) EOQ end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 516 def select_resource_relationship(resource) if resource.belongs_to? fk = %Q{"#{resource.parent.table_name}"."#{resource.foreign_key}"} <<~EOQ '#{resource.json_key}', jsonb_build_object('data', CASE WHEN #{fk} IS NULL THEN NULL ELSE jsonb_build_object('id', #{fk}::text, 'type', '#{resource.json_type}') END) EOQ elsif resource.has_many? or resource.has_one? refl = resource.reflection <<~EOQ '#{resource.json_key}', jsonb_build_object(#{refl.include_data ? %Q{'data', "rel_#{resource.cte_name}".j} : ''} #{refl.include_data && refl.links.any? ? ',' : ''} #{refl.links.any? ? %Q{'links', jsonb_build_object(#{select_resource_relationship_links(resource, refl)})} : ''}) EOQ else raise "Unknown kind of field reflection for #{resource.full_name}" end end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 508 def select_resource_relationship_links(resource, reflection) reflection.links.map {|link_name, link_parts| <<~EOQ '#{link_name}', CONCAT(#{link_parts.join(%Q{, "#{resource.parent.table_name}"."#{resource.parent.primary_key_attr}", })}) EOQ }.join(",\n") end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 539 def select_resource_relationships(resource) fields = reflection_fields_for(resource) children = fields.map{|f| get_json_thing(resource, f)} if children.any? <<~EOQ jsonb_build_object(#{children.map{|ch| select_resource_relationship(ch)}.join(', ')}) EOQ else nil end end
Returns all the attributes listed in the serializer, after checking `include_foo?` methods.
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 729 def serializer_attributes(resource) ms = Set.new(resource.serializer.instance_methods) resource.serializer._attributes.select{|f| if ms.include? "include_#{f}?".to_sym ser = resource.serializer.new(nil, @options) ser.send("include_#{f}?".to_sym) else true end } end
Returns all the relationships listed in the serializer, after checking `include_foo?` methods.
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 743 def serializer_reflections(resource) ms = Set.new(resource.serializer.instance_methods) resource.serializer._reflections.keys.select{|f| if ms.include? "include_#{f}?".to_sym ser = resource.serializer.new(nil, @options) ser.send("include_#{f}?".to_sym) else true end } end
# File lib/active_model_serializers/adapter/json_api_pg.rb, line 796 def to_sql table_name = base_resource.table_name maybe_included = if include_selects.any? %Q{, 'included', inc.j} else '' end return <<~EOQ WITH t AS ( #{base_relation.select(%Q{"#{base_resource.table_name}".*}).to_sql} ), t2 AS ( #{many? ? "SELECT COALESCE(jsonb_agg(#{select_resource(base_resource)}), '[]') AS j" : "SELECT #{select_resource(base_resource)} AS j"} FROM t AS "#{base_resource.table_name}" #{join_resource_relationships(base_resource)} ), #{include_ctes} #{include_jbsses} all_jbsses AS ( SELECT '{}'::jsonb AS j WHERE 1=0 #{include_selects.join("\n")} ), inc AS ( SELECT COALESCE(jsonb_agg(j), '[]') AS j FROM all_jbsses ) SELECT jsonb_build_object('data', t2.j #{maybe_included}) FROM t2 CROSS JOIN inc EOQ end