CartoDB/cartodb20

View on GitHub
app/models/visualization/collection.rb

Summary

Maintainability
D
2 days
Test Coverage
require 'set'
require_relative './member'
require_relative './overlays'
require_relative '../../../services/data-repository/structures/collection'

module CartoDB
  module Visualization
    SIGNATURE           = 'visualizations'
    PARTIAL_MATCH_QUERY = %Q{
      to_tsvector(
        'english', coalesce(name, '') || ' '
        || coalesce(description, '')
      ) @@ plainto_tsquery('english', ?)
      OR CONCAT(name, ' ', description) ILIKE ?
    }

    class << self
      attr_accessor :repository
    end

    class Collection
      # 'unauthenticated' overrides other filters
      # 'user_id' filtered by default if present upon fetch()
      # 'locked' is filtered but before the rest
      # 'exclude_shared' and
      # 'only_shared' are other filtes applied
      # 'only_liked'
      AVAILABLE_FIELD_FILTERS   = %w{name type description map_id privacy id parent_id}

      # Keys in this list are the only filters that should be kept for calculating totals (if present)
      FILTERS_ALLOWED_AT_TOTALS = [ :type, :user_id, :unauthenticated ]

      FILTER_SHARED_YES = 'yes'
      FILTER_SHARED_NO = 'no'
      FILTER_SHARED_ONLY = 'only'

      ALLOWED_ORDERING_FIELDS = [:mapviews, :row_count, :size].freeze

      # Same as services/data-repository/backend/sequel.rb
      PAGE          = 1
      PER_PAGE      = 300

      ALL_RECORDS = 999999

      def initialize(options={})
        @total_entries = 0

        @collection = DataRepository::Collection.new(
          signature:    SIGNATURE,
          repository:   options.fetch(:repository, Visualization.repository),
          member_class: Member
        )
        @can_paginate = true
        @lazy_order_by = nil
        @unauthenticated_flag = false
        @user_id = nil
        @type = nil
      end

      DataRepository::Collection::INTERFACE.each do |method_name|
        define_method(method_name) do |*arguments, &block|
          result = collection.send(method_name, *arguments, &block)
          return self if result.is_a?(DataRepository::Collection)
          result
        end
      end

      # NOTES:
      # - if 'user_id' is present as filter, will fetch visualizations shared with the user,
      #   except if 'exclude_shared' filter is also present and true,
      # - 'only_shared' forces to use different flow because if there are no shared there's nothing else to do
      # - 'locked' filter has special behaviour
      # - If 'only_liked' it will return all favorited visualizations, not only user's.
      def fetch(filters={})
        filters = filters.dup   # Avoid changing state
        @user_id = filters.fetch(:user_id, nil)
        filters = restrict_filters_if_unauthenticated(filters)
        dataset = compute_sharing_filter_dataset(filters)
        dataset = compute_liked_filter_dataset(dataset, filters)

        if dataset.nil?
          @total_entries = 0
          collection.storage = Set.new
        else
          dataset = apply_filters(dataset, filters)
          @total_entries = dataset.count
          collection.storage = Set.new(paginate_and_get_entries(dataset, filters))
        end

        self
      end

      def delete_if(&block)
        collection.delete_if(&block)
      end

      # This method is not used for anything but called from the DataRepository::Collection interface above
      def store
        self
      end

      # Counts the total results, only taking into account general filters like type or privacy or sharing options
      # so no name or map_id filtering.
      def count_total(filters={})
        total_user_entries = 0

        cleaned_filters = filters.keep_if { |key, |
          FILTERS_ALLOWED_AT_TOTALS.include?(key.to_sym)
        }
        cleaned_filters.merge!({ exclude_shared: true })

        cleaned_filters = restrict_filters_if_unauthenticated(cleaned_filters)
        dataset = compute_sharing_filter_dataset(cleaned_filters)

        unless dataset.nil?
          dataset = apply_filters(dataset, cleaned_filters)
          total_user_entries = dataset.count
        end

        total_user_entries
      end

      def count_query(filters={})
        dataset = compute_sharing_filter_dataset(filters)
        if dataset.nil?
          0
        else
          dataset = compute_liked_filter_dataset(dataset, filters)
          dataset.nil? ? 0 : apply_filters(dataset, filters).count
        end
      end

      def destroy
        map(&:delete)
        self
      end

      def to_poro
        map { |member| member.to_hash(related: false, table_data: true) }
      end

      # Warning, this is a cached count, do not use if adding/removing collection items
      # @throws KeyError
      def total_shared_entries(type = nil)
        total = 0
        unless @unauthenticated_flag
          if @user_id.nil?
            raise KeyError.new("Can't retrieve shared count without specifying user id")
          else
            total = user_shared_entities_count(type) + organization_shared_entities_count(type)
          end
        end
        total
      end

      attr_reader :total_entries

      private

      attr_reader :collection

      def paginate_and_get_entries(dataset, filters)
        if @can_paginate
          dataset = repository.paginate(dataset, filters, @total_entries)
          dataset.map { |attributes|
            Visualization::Member.new(attributes)
          }
        else
          items = dataset.map { |attributes|
            Visualization::Member.new(attributes)
          }
          items = lazy_order_by(items, @lazy_order_by)
          # Manual paging
          page = (filters.delete(:page) || PAGE).to_i
          per_page = (filters.delete(:per_page) || PER_PAGE).to_i
          items.slice((page - 1) * per_page, per_page)
        end
      end

      def user_shared_entities_count(type = nil)
        type ||= @type
        entities = Carto::SharedEntity.select(:entity_id)
                                      .where(
                                        recipient_id: @user_id,
                                        entity_type: Carto::SharedEntity::ENTITY_TYPE_VISUALIZATION,
                                        recipient_type: Carto::SharedEntity::RECIPIENT_TYPE_USER
                                      )
        entities = if type.nil?
                     entities.joins(:visualization, visualizations__id: :entity_id)
                   else
                     entities.joins(:visualization, visualizations__id: :entity_id, type: type)
                   end
        entities.count
      end

      def organization_shared_entities_count(type)
        type ||= @type
        user = ::User.where(id: @user_id).first
        if user.nil? || user.organization.nil?
          0
        else
          entities = Carto::SharedEntity.select(:entity_id)
                                        .where(
                                          recipient_id: user.organization_id,
                                          entity_type: Carto::SharedEntity::ENTITY_TYPE_VISUALIZATION,
                                          recipient_type: Carto::SharedEntity::RECIPIENT_TYPE_ORGANIZATION
                                        )
          entities = if type.nil?
                       entities.join(:visualizations, visualizations__id: :entity_id)
                     else
                       entities.join(:visualizations, visualizations__id: :entity_id, type: type)
                     end
          entities.count
        end
      end

      # If special filter unauthenticated: true is present, will restrict data
      def restrict_filters_if_unauthenticated(filters)
        @unauthenticated_flag = false
        unless filters.delete(:unauthenticated).nil?
          filters[:only_shared] = false
          filters[:exclude_shared] = true
          filters[:privacy] = Visualization::Member::PRIVACY_PUBLIC
          filters.delete(:locked)
          filters.delete(:map_id)
          @unauthenticated_flag = true
        end
        filters
      end

      def base_collection(filters)
        only_liked = filters.fetch(:only_liked, 'false')
        if only_liked == true || only_liked == 'true'
          user_id = filters[:user_id]
          dataset = repository.collection({}, [])
          dataset = add_liked_by_conditions_to_dataset(dataset, user_id)
        else
          repository.collection(filters, %w{ user_id })
        end
      end

      def compute_sharing_filter_dataset(filters)
        shared_filter = filters.delete(:shared)
        case shared_filter
          when FILTER_SHARED_YES
            filters[:only_shared] = false
            filters[:exclude_shared] = false
          when FILTER_SHARED_NO
            filters[:only_shared] = false
            filters[:exclude_shared] = true
          when FILTER_SHARED_ONLY
            filters[:only_shared] = true
            filters[:exclude_shared] = false
        end

        if filters[:only_shared].present? && filters[:only_shared].to_s == 'true'
          dataset = repository.collection
          dataset = filter_by_only_shared(dataset, filters)
        else
          dataset = base_collection(filters)
          locked_filter = filters.delete(:locked)
          unless locked_filter.nil?
            if locked_filter.to_s == 'true'
              locked_filter = true
              filters[:exclude_shared] = true
            else
              locked_filter = locked_filter.to_s == 'false' ? false : nil
            end
          end
          dataset = repository.apply_filters(dataset, {locked: locked_filter}, ['locked']) unless locked_filter.nil?
          dataset = include_shared_entities(dataset, filters)
        end
        dataset
      end

      def compute_liked_filter_dataset(dataset, filters)
        only_liked = filters.delete(:only_liked)
        if [true, 'true'].include?(only_liked)
          if @user_id.nil?
            nil
          else
            filters[:order] = :updated_at if filters.fetch(:order, nil).nil?

            liked_vis = user_liked_vis(@user_id)
            if liked_vis.nil? || liked_vis.empty?
              nil
            else
              dataset.where(id: liked_vis)
            end
          end
        else
          dataset
        end
      end

      def add_liked_by_conditions_to_dataset(dataset, user_id)
        user_shared_vis = user_shared_vis(user_id)
        dataset.where {
          Sequel.|(
            { privacy: [CartoDB::Visualization::Member::PRIVACY_PUBLIC, CartoDB::Visualization::Member::PRIVACY_LINK] },
            { user_id: user_id },
            { visualizations__id: user_shared_vis }
          )
        }
        # TODO: this probably introduces duplicates. See #2899.
        # Should be removed when like count and list matches for organizations
        # include_shared_entities(dataset, { user_id: user_id } )
      end

      def apply_filters(dataset, filters)
        @type = filters.fetch(:type, nil)
        @type = nil if @type == ''
        applied_filters = AVAILABLE_FIELD_FILTERS.dup
        applied_filters = applied_filters.delete_if { |k, v| k == 'type' } if @type.nil?
        dataset = repository.apply_filters(dataset, filters, applied_filters)
        # TODO: symbolize types key
        dataset = filter_by_types(dataset, filters.fetch('types', nil))
        dataset = filter_by_tags(dataset, tags_from(filters))
        dataset = filter_by_partial_match(dataset, filters.delete(:q))
        dataset = filter_by_kind(dataset, filters.delete(:exclude_raster))
        dataset = filter_by_min_date('updated_at', dataset, filters.delete(:min_updated_at)) if filters.has_key?(:min_updated_at)
        dataset = filter_by_min_date('created_at', dataset, filters.delete(:min_created_at)) if filters.has_key?(:min_created_at)
        dataset = filter_by_ids(dataset, filters.delete(:ids))
        dataset = filter_by_permission_id(dataset, filters.delete(:permission_id))
        dataset = filter_by_version(dataset, filters.delete(:version))
        order_desc = filters.delete(:order_asc_desc)
        order(dataset, filters.delete(:order), order_desc.nil? || order_desc == :desc)
      end

      # Note: Not implemented ascending order for now, all are descending sorts
      def lazy_order_by(objects, field)
        case field
        when :mapviews
          lazy_order_by_mapviews(objects)
        when :row_count
          lazy_order_by_row_count(objects)
        when :size
          lazy_order_by_size(objects)
        end
      end

      def lazy_order_by_mapviews(objects)
        # Stats have format [ date, value ]
        viz_and_views = objects.map { |viz| [viz, viz.stats.map { |o| o[1] }.reduce(0, :+)] }
        viz_and_views.sort! { |vv_a, vv_b| vv_b[1] <=> vv_a[1] }
        viz_and_views.map { |vv| vv[0] }
      end

      def lazy_order_by_row_count(objects)
        viz_and_rows = objects.map { |obj| [obj, (obj.table ? obj.table.row_count_and_size.fetch(:row_count, 0) : 0)] }
        viz_and_rows.sort! { |vr_a, vr_b| vr_b[1] <=> vr_a[1] }
        viz_and_rows.map { |vr| vr[0] }
      end

      def lazy_order_by_size(objects)
        viz_and_size = objects.map { |obj| [obj, (obj.table ? obj.table.row_count_and_size.fetch(:size, 0) : 0)] }
        viz_and_size.sort! { |vs_a, vs_b| vs_b[1] <=> vs_a[1] }
        viz_and_size.map { |vs| vs[0] }
      end

      # Note: Not implemented ascending order for now
      def order_by_related_attribute(dataset, criteria)
        @can_paginate = false
        @lazy_order_by = criteria
        dataset
      end

      def order_by_base_attribute(dataset, criteria, order_desc = true)
        @can_paginate = true
        dataset.order(Sequel.send(order_desc.nil? || order_desc == true ? :desc : :asc, criteria))
      end

      # Allows to order by any CartoDB::Visualization::Member attribute (eg: updated_at, created_at), plus:
      # - mapviews
      # - row_count
      # - size
      # TODO: order_asc_desc only works for base attributes
      def order(dataset, criteria=nil, order_desc = true)
        return dataset if criteria.nil? || criteria.empty?
        criteria = criteria.to_sym
        if ALLOWED_ORDERING_FIELDS.include? criteria
          order_by_related_attribute(dataset, criteria)
        else
          order_by_base_attribute(dataset, criteria, order_desc)
        end
      end

      def filter_by_types(dataset, types = nil)
        return dataset if types.nil? || types == ''
        types_array = types.is_a?(String) ? types.split(',') : types
        dataset.where(:type => types_array)
      end

      def filter_by_tags(dataset, tags=[])
        return dataset if tags.nil? || tags.empty?
        placeholders = tags.length.times.map { '?' }.join(', ')
        filter       = "tags && ARRAY[#{placeholders}]"

        dataset.where([filter].concat(tags))
      end

      def filter_by_partial_match(dataset, pattern=nil)
        return dataset if pattern.nil? || pattern.empty?
        dataset.where(PARTIAL_MATCH_QUERY, pattern, "%#{pattern}%")
      end

      def filter_by_kind(dataset, filter_value)
        return dataset if filter_value.nil? || !filter_value
        dataset.where('kind=?', Member::KIND_GEOM)
      end

      def filter_by_min_date(column, dataset, date_filter)
        return dataset if !date_filter
        included = date_filter.has_key?(:include) ? date_filter[:include] : false
        comparison = included ? '>=' : '>'
        dataset.where("#{column} #{comparison} ?", date_filter[:date])
      end

      def filter_by_ids(dataset, ids)
        return dataset if !ids
        dataset.where(:id => ids)
      end

      def filter_by_permission_id(dataset, permission_id)
        return dataset if permission_id.nil?
        dataset.where(permission_id: permission_id)
      end

      def filter_by_version(dataset, version)
        return dataset if version.nil?
        dataset.where(version: version)
      end

      def filter_by_only_shared(dataset, filters)
        return dataset \
          unless (filters[:user_id].present? && filters[:only_shared].present? && filters[:only_shared].to_s == 'true')

        shared_vis = user_shared_vis(filters[:user_id])

        if shared_vis.nil? || shared_vis.empty?
          nil
        else
          dataset.where(id: shared_vis).exclude(user_id: filters[:user_id])
        end
      end

      def include_shared_entities(dataset, filters)
        return dataset unless filters[:user_id].present?
        return dataset if filters[:exclude_shared].present? && filters[:exclude_shared].to_s == 'true'

        shared_vis = user_shared_vis(filters[:user_id])

        return dataset if shared_vis.nil? || shared_vis.empty?
        dataset.or(id: shared_vis)
      end

      def user_shared_vis(user_id)
        recipient_ids = user_id.is_a?(Array) ? user_id : [user_id]
        ::User.where(id: user_id).each { |user|
          if user.has_organization?
            recipient_ids << user.organization.id
          end
        }

        Carto::SharedEntity.where(
          recipient_id: recipient_ids,
          entity_type: Carto::SharedEntity::ENTITY_TYPE_VISUALIZATION
        ).pluck(:entity_id)
      end

      def user_liked_vis(user_id)
        Carto::Like.where(actor: user_id).all.map{ |like| like.subject }
      end

      def tags_from(filters={})
        filters.delete(:tags).to_s.split(',')
      end

    end
  end
end