summaryrefslogtreecommitdiff
path: root/lib/gitlab/json_cache.rb
blob: 9a0b2b35112937b07c39374bed8d2c99817b347d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# 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
        expanded_cache_key << Rails.version
      end

      expanded_cache_key.join(':')
    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)
      value = ActiveSupport::JSON.decode(raw.to_s)

      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)
      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
      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, {})
    end

    def new_record?(raw, klass)
      raw.fetch(klass.primary_key, nil).blank?
    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