json_cache.rb 3.07 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
# frozen_string_literal: true

module Gitlab
  class JsonCache
    attr_reader :backend, :cache_key_with_version, :namespace

    def initialize(options = {})
      @backend = options.fetch(:backend, Rails.cache)
      @namespace = options.fetch(:namespace, nil)
      @cache_key_with_version = options.fetch(:cache_key_with_version, true)
    end

    def active?
      if backend.respond_to?(:active?)
        backend.active?
      else
        true
      end
    end

    def cache_key(key)
      expanded_cache_key = [namespace, key].compact

      if cache_key_with_version
25
        expanded_cache_key << [Gitlab::VERSION, Rails.version]
26 27
      end

28
      expanded_cache_key.flatten.join(':').freeze
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
    end

    def expire(key)
      backend.delete(cache_key(key))
    end

    def read(key, klass = nil)
      value = backend.read(cache_key(key))
      value = parse_value(value, klass) if value
      value
    end

    def write(key, value, options = nil)
      backend.write(cache_key(key), value.to_json, options)
    end

    def fetch(key, options = {}, &block)
      klass = options.delete(:as)
      value = read(key, klass)

      return value unless value.nil?

      value = yield

      write(key, value, options)

      value
    end

    private

    def parse_value(raw, klass)
61
      value = ActiveSupport::JSON.decode(raw.to_s)
62 63 64 65 66 67 68 69 70 71 72 73

      case value
      when Hash then parse_entry(value, klass)
      when Array then parse_entries(value, klass)
      else
        value
      end
    rescue ActiveSupport::JSON.parse_error
      nil
    end

    def parse_entry(raw, klass)
74 75 76 77 78 79 80 81 82
      return unless valid_entry?(raw, klass)
      return klass.new(raw) unless klass.ancestors.include?(ActiveRecord::Base)

      # When the cached value is a persisted instance of ActiveRecord::Base in
      # some cases a relation can return an empty collection becauses scope.none!
      # is being applied on ActiveRecord::Associations::CollectionAssociation#scope
      # when the new_record? method incorrectly returns false.
      #
      # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9903#note_145329964
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
      klass
        .allocate
        .init_with(
          "attributes" => attributes_for(klass, raw),
          "new_record" => new_record?(raw, klass)
        )
    end

    def attributes_for(klass, raw)
      # We have models that leave out some fields from the JSON export for
      # security reasons, e.g. models that include the CacheMarkdownField.
      # The ActiveRecord::AttributeSet we build from raw does know about
      # these columns so we need manually set them.
      missing_attributes = (klass.columns.map(&:name) - raw.keys)
      missing_attributes.each { |column| raw[column] = nil }

      klass.attributes_builder.build_from_database(raw, {})
100 101 102 103
    end

    def new_record?(raw, klass)
      raw.fetch(klass.primary_key, nil).blank?
104 105 106 107 108 109 110 111 112 113 114 115 116
    end

    def valid_entry?(raw, klass)
      return false unless klass && raw.is_a?(Hash)

      (raw.keys - klass.attribute_names).empty?
    end

    def parse_entries(values, klass)
      values.map { |value| parse_entry(value, klass) }.compact
    end
  end
end