wcc-contentful 0.3.0 → 1.0.0.pre.rc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/.rspec +1 -1
- data/Guardfile +43 -0
- data/README.md +161 -11
- data/Rakefile +3 -6
- data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
- data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
- data/bin/console +4 -3
- data/bin/rails +2 -0
- data/config/routes.rb +1 -1
- data/doc +1 -0
- data/lib/tasks/download_schema.rake +12 -0
- data/lib/wcc/contentful.rb +69 -45
- data/lib/wcc/contentful/active_record_shim.rb +72 -0
- data/lib/wcc/contentful/configuration.rb +177 -46
- data/lib/wcc/contentful/content_type_indexer.rb +14 -0
- data/lib/wcc/contentful/downloads_schema.rb +112 -0
- data/lib/wcc/contentful/engine.rb +33 -14
- data/lib/wcc/contentful/event.rb +171 -0
- data/lib/wcc/contentful/events.rb +41 -0
- data/lib/wcc/contentful/exceptions.rb +3 -33
- data/lib/wcc/contentful/indexed_representation.rb +2 -2
- data/lib/wcc/contentful/instrumentation.rb +31 -0
- data/lib/wcc/contentful/link.rb +28 -0
- data/lib/wcc/contentful/link_visitor.rb +122 -0
- data/lib/wcc/contentful/middleware.rb +7 -0
- data/lib/wcc/contentful/middleware/store.rb +158 -0
- data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
- data/lib/wcc/contentful/model.rb +37 -4
- data/lib/wcc/contentful/model_builder.rb +1 -0
- data/lib/wcc/contentful/model_methods.rb +40 -15
- data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
- data/lib/wcc/contentful/rake.rb +4 -0
- data/lib/wcc/contentful/rspec.rb +46 -0
- data/lib/wcc/contentful/services.rb +61 -27
- data/lib/wcc/contentful/simple_client.rb +81 -25
- data/lib/wcc/contentful/simple_client/management.rb +43 -10
- data/lib/wcc/contentful/simple_client/response.rb +61 -22
- data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
- data/lib/wcc/contentful/store.rb +7 -66
- data/lib/wcc/contentful/store/README.md +85 -0
- data/lib/wcc/contentful/store/base.rb +34 -119
- data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
- data/lib/wcc/contentful/store/factory.rb +186 -0
- data/lib/wcc/contentful/store/instrumentation.rb +55 -0
- data/lib/wcc/contentful/store/interface.rb +82 -0
- data/lib/wcc/contentful/store/memory_store.rb +27 -24
- data/lib/wcc/contentful/store/postgres_store.rb +268 -101
- data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
- data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
- data/lib/wcc/contentful/store/query.rb +246 -0
- data/lib/wcc/contentful/store/query/interface.rb +63 -0
- data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
- data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
- data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
- data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
- data/lib/wcc/contentful/sync_engine.rb +181 -0
- data/lib/wcc/contentful/test.rb +7 -0
- data/lib/wcc/contentful/test/attributes.rb +56 -0
- data/lib/wcc/contentful/test/double.rb +76 -0
- data/lib/wcc/contentful/test/factory.rb +101 -0
- data/lib/wcc/contentful/version.rb +1 -1
- data/wcc-contentful.gemspec +28 -14
- metadata +248 -152
- data/.circleci/config.yml +0 -51
- data/.gitignore +0 -26
- data/.rubocop.yml +0 -242
- data/.rubocop_todo.yml +0 -19
- data/.travis.yml +0 -5
- data/CHANGELOG.md +0 -180
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -8
- data/LICENSE.txt +0 -21
- data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
- data/lib/generators/wcc/USAGE +0 -24
- data/lib/generators/wcc/model_generator.rb +0 -90
- data/lib/generators/wcc/templates/.keep +0 -0
- data/lib/generators/wcc/templates/Procfile +0 -3
- data/lib/generators/wcc/templates/contentful_shell_wrapper +0 -385
- data/lib/generators/wcc/templates/menu/generated_add_menus.ts +0 -192
- data/lib/generators/wcc/templates/menu/models/menu.rb +0 -23
- data/lib/generators/wcc/templates/menu/models/menu_button.rb +0 -23
- data/lib/generators/wcc/templates/page/generated_add_pages.ts +0 -50
- data/lib/generators/wcc/templates/page/models/page.rb +0 -23
- data/lib/generators/wcc/templates/release +0 -9
- data/lib/generators/wcc/templates/wcc_contentful.rb +0 -17
- data/lib/wcc/contentful/client_ext.rb +0 -28
- data/lib/wcc/contentful/graphql.rb +0 -14
- data/lib/wcc/contentful/graphql/builder.rb +0 -177
- data/lib/wcc/contentful/graphql/types.rb +0 -54
- data/lib/wcc/contentful/model/dropdown_menu.rb +0 -7
- data/lib/wcc/contentful/model/menu.rb +0 -6
- data/lib/wcc/contentful/model/menu_button.rb +0 -16
- data/lib/wcc/contentful/model/page.rb +0 -8
- data/lib/wcc/contentful/model/redirect.rb +0 -19
- data/lib/wcc/contentful/model_validators.rb +0 -121
- data/lib/wcc/contentful/model_validators/dsl.rb +0 -166
- data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
- data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
@@ -0,0 +1,186 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require_relative 'memory_store'
|
5
|
+
require_relative 'cdn_adapter'
|
6
|
+
require_relative '../middleware/store'
|
7
|
+
require_relative '../middleware/store/caching_middleware'
|
8
|
+
|
9
|
+
module WCC::Contentful::Store
|
10
|
+
# This factory presents a DSL for configuring the store stack. The store stack
|
11
|
+
# sits in between the Model layer and the datastore, which can be Contentful
|
12
|
+
# or something else like Postgres.
|
13
|
+
#
|
14
|
+
# A set of "presets" are available to get pre-configured stacks based on what
|
15
|
+
# we've found most useful.
|
16
|
+
class Factory
|
17
|
+
attr_reader :preset, :options, :config
|
18
|
+
|
19
|
+
# Set the base store instance.
|
20
|
+
attr_accessor :store
|
21
|
+
|
22
|
+
# An array of tuples that set up and configure a Store middleware.
|
23
|
+
def middleware
|
24
|
+
@middleware ||= self.class.default_middleware.dup
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize(config = WCC::Contentful.configuration, preset = :direct, options = nil)
|
28
|
+
@config = config
|
29
|
+
@preset = preset || :custom
|
30
|
+
@options = [*options] || []
|
31
|
+
|
32
|
+
# Infer whether they passed in a store implementation object or class
|
33
|
+
if class_implements_store_interface?(@preset) ||
|
34
|
+
object_implements_store_interface?(@preset)
|
35
|
+
@options.unshift(@preset)
|
36
|
+
@preset = :custom
|
37
|
+
end
|
38
|
+
|
39
|
+
configure_preset(@preset)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Adds a middleware to the chain. Use a block here to configure the middleware
|
43
|
+
# after it has been created.
|
44
|
+
def use(middleware, *middleware_params, &block)
|
45
|
+
configure_proc = block_given? ? Proc.new(&block) : nil
|
46
|
+
self.middleware << [middleware, middleware_params, configure_proc]
|
47
|
+
end
|
48
|
+
|
49
|
+
def replace(middleware, *middleware_params, &block)
|
50
|
+
idx = self.middleware.find_index { |m| m[0] == middleware }
|
51
|
+
raise ArgumentError, "Middleware #{middleware} not present" if idx.nil?
|
52
|
+
|
53
|
+
configure_proc = block_given? ? Proc.new(&block) : nil
|
54
|
+
self.middleware[idx] = [middleware, middleware_params, configure_proc]
|
55
|
+
end
|
56
|
+
|
57
|
+
def unuse(middleware)
|
58
|
+
idx = self.middleware.find_index { |m| m[0] == middleware }
|
59
|
+
return if idx.nil?
|
60
|
+
|
61
|
+
self.middleware.delete_at idx
|
62
|
+
end
|
63
|
+
|
64
|
+
def build(services = WCC::Contentful::Services.instance)
|
65
|
+
store_instance = build_store(services)
|
66
|
+
options = {
|
67
|
+
config: config,
|
68
|
+
services: services
|
69
|
+
}
|
70
|
+
middleware.reverse
|
71
|
+
.reduce(store_instance) do |memo, middleware_config|
|
72
|
+
# May have added a middleware with `middleware << MyMiddleware.new`
|
73
|
+
middleware_config = [middleware_config] unless middleware_config.is_a? Array
|
74
|
+
|
75
|
+
middleware, params, configure_proc = middleware_config
|
76
|
+
middleware_options = options.merge((params || []).extract_options!)
|
77
|
+
middleware = middleware.call(memo, *params, **middleware_options)
|
78
|
+
middleware&.instance_exec(&configure_proc) if configure_proc
|
79
|
+
middleware || memo
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def validate!
|
84
|
+
unless preset.nil? || PRESETS.include?(preset)
|
85
|
+
raise ArgumentError, "Please use one of #{PRESETS} instead of #{preset}"
|
86
|
+
end
|
87
|
+
|
88
|
+
middleware.each do |m|
|
89
|
+
next if m[0].respond_to?(:call)
|
90
|
+
|
91
|
+
raise ArgumentError, "The middleware '#{m[0]&.try(:name) || m[0]}' cannot be applied! " \
|
92
|
+
'It must respond to :call'
|
93
|
+
end
|
94
|
+
|
95
|
+
validate_store!(store)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Sets the "eager sync" preset using one of the preregistered stores like :postgres
|
99
|
+
def preset_eager_sync
|
100
|
+
store = options.shift || :memory
|
101
|
+
store = SYNC_STORES[store]&.call(config, *options) if store.is_a?(Symbol)
|
102
|
+
self.store = store
|
103
|
+
end
|
104
|
+
|
105
|
+
# Configures a "lazy sync" preset which caches direct lookups but hits Contentful
|
106
|
+
# for any missing information. The cache is kept up to date by the sync engine.
|
107
|
+
def preset_lazy_sync
|
108
|
+
preset_direct
|
109
|
+
use(WCC::Contentful::Middleware::Store::CachingMiddleware,
|
110
|
+
ActiveSupport::Cache.lookup_store(*options))
|
111
|
+
end
|
112
|
+
|
113
|
+
# Configures the default "direct" preset which passes everything through to
|
114
|
+
# Contentful CDN
|
115
|
+
def preset_direct
|
116
|
+
self.store = CDNAdapter.new(preview: options.include?(:preview))
|
117
|
+
end
|
118
|
+
|
119
|
+
def preset_custom
|
120
|
+
self.store = options.shift
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def validate_store!(store)
|
126
|
+
raise ArgumentError, 'No store provided' unless store
|
127
|
+
|
128
|
+
return true if class_implements_store_interface?(store) ||
|
129
|
+
object_implements_store_interface?(store)
|
130
|
+
|
131
|
+
methods = [*store.try(:instance_methods), *store.try(:methods)]
|
132
|
+
WCC::Contentful::Store::Interface::INTERFACE_METHODS.each do |method|
|
133
|
+
next if methods.include?(method)
|
134
|
+
|
135
|
+
raise ArgumentError, "Custom store '#{store}' must respond to the #{method} method"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def configure_preset(preset)
|
140
|
+
unless respond_to?("preset_#{preset}")
|
141
|
+
raise ArgumentError, "Don't know how to build content delivery method '#{preset}'"
|
142
|
+
end
|
143
|
+
|
144
|
+
public_send("preset_#{preset}")
|
145
|
+
end
|
146
|
+
|
147
|
+
def build_store(services)
|
148
|
+
store_class = store
|
149
|
+
store =
|
150
|
+
if object_implements_store_interface?(store_class)
|
151
|
+
store_class
|
152
|
+
else
|
153
|
+
store_class.new(config, *options - [store_class])
|
154
|
+
end
|
155
|
+
|
156
|
+
# Inject services into the custom store class
|
157
|
+
(WCC::Contentful::SERVICES - %i[store preview_store]).each do |s|
|
158
|
+
next unless store.respond_to?("#{s}=")
|
159
|
+
|
160
|
+
store.public_send("#{s}=",
|
161
|
+
services.public_send(s))
|
162
|
+
end
|
163
|
+
|
164
|
+
store
|
165
|
+
end
|
166
|
+
|
167
|
+
def class_implements_store_interface?(klass)
|
168
|
+
(WCC::Contentful::Store::Interface::INTERFACE_METHODS -
|
169
|
+
(klass.try(:instance_methods) || [])).empty?
|
170
|
+
end
|
171
|
+
|
172
|
+
def object_implements_store_interface?(object)
|
173
|
+
(WCC::Contentful::Store::Interface::INTERFACE_METHODS -
|
174
|
+
(object.try(:methods) || [])).empty?
|
175
|
+
end
|
176
|
+
|
177
|
+
class << self
|
178
|
+
# The middleware that by default lives at the top of the middleware stack.
|
179
|
+
def default_middleware
|
180
|
+
[
|
181
|
+
[WCC::Contentful::Store::InstrumentationMiddleware]
|
182
|
+
].freeze
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../instrumentation'
|
4
|
+
require_relative '../middleware/store'
|
5
|
+
|
6
|
+
module WCC::Contentful::Store
|
7
|
+
module Instrumentation
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
include WCC::Contentful::Instrumentation
|
12
|
+
|
13
|
+
def _instrumentation_event_prefix
|
14
|
+
'.store.contentful.wcc'
|
15
|
+
end
|
16
|
+
|
17
|
+
prepend InstrumentationWrapper
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
module InstrumentationWrapper
|
22
|
+
def find(key, **options)
|
23
|
+
_instrument 'find', id: key, options: options do
|
24
|
+
super
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def index(json)
|
29
|
+
_instrument 'index', id: json.dig('sys', 'id') do
|
30
|
+
super
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def find_by(**params)
|
35
|
+
_instrument 'find_by', params.slice(:content_type, :filter, :options) do
|
36
|
+
super
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def find_all(**params)
|
41
|
+
# end happens when query is executed - todo.
|
42
|
+
_instrument 'find_all', params.slice(:content_type, :options)
|
43
|
+
super
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class InstrumentationMiddleware
|
48
|
+
include WCC::Contentful::Middleware::Store
|
49
|
+
include WCC::Contentful::Store::Instrumentation
|
50
|
+
|
51
|
+
delegate(*WCC::Contentful::Store::Interface::INTERFACE_METHODS, to: :store)
|
52
|
+
|
53
|
+
# TODO: use DelegatingQuery to instrument the moment of find_all query execution?
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WCC::Contentful::Store
|
4
|
+
# This module represents the common interface of all Store implementations.
|
5
|
+
# It is documentation ONLY and does not add functionality.
|
6
|
+
#
|
7
|
+
# This is distinct from WCC::Contentful::Store::Base, because certain helpers
|
8
|
+
# exposed publicly by that abstract class are not part of the actual interface
|
9
|
+
# and can change without a major version update.
|
10
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
11
|
+
module Interface
|
12
|
+
# TODO: legit implement Sorbet typechecks
|
13
|
+
# https://github.com/watermarkchurch/wcc-contentful/pull/183
|
14
|
+
|
15
|
+
# extend T::Sig
|
16
|
+
# extend T::Helpers
|
17
|
+
# interface!
|
18
|
+
|
19
|
+
# Returns true if this store can persist entries and assets which are
|
20
|
+
# retrieved from the sync API.
|
21
|
+
# sig {abstract.returns(T::Boolean)}
|
22
|
+
def index?
|
23
|
+
raise NotImplementedError, "#{self.class} does not implement #index?"
|
24
|
+
end
|
25
|
+
|
26
|
+
# Processes a data point received via the Sync API. This can be a published
|
27
|
+
# entry or asset, or a 'DeletedEntry' or 'DeletedAsset'. The default
|
28
|
+
# implementation calls into #set and #delete to perform the appropriate
|
29
|
+
# operations in the store.
|
30
|
+
# sig {abstract.params(json: T.any(Entry, Asset, DeletedEntry, DeletedAsset))
|
31
|
+
# .returns(T.any(Entry, Asset, nil))}
|
32
|
+
def index(_json)
|
33
|
+
raise NotImplementedError, "#{self.class} does not implement #index"
|
34
|
+
end
|
35
|
+
|
36
|
+
# Finds an entry by it's ID. The returned entry is a JSON hash
|
37
|
+
# @abstract Subclasses should implement this at a minimum to provide data
|
38
|
+
# to the WCC::Contentful::Model API.
|
39
|
+
# sig {abstract.params(id: String).returns(T.any(Entry, Asset))}
|
40
|
+
def find(_id)
|
41
|
+
raise NotImplementedError, "#{self.class} does not implement #find"
|
42
|
+
end
|
43
|
+
|
44
|
+
# Finds the first entry matching the given filter. A content type is required.
|
45
|
+
#
|
46
|
+
# @param [String] content_type The ID of the content type to search for.
|
47
|
+
# @param [Hash] filter A set of key-value pairs defining filter operations.
|
48
|
+
# See WCC::Contentful::Store::Base::Query
|
49
|
+
# @param [Hash] options An optional set of additional parameters to the query
|
50
|
+
# defining for example include depth. Not all store implementations respect all options.
|
51
|
+
# sig {abstract.params(
|
52
|
+
# content_type: String,
|
53
|
+
# filter: T.nilable(T::Hash[T.any(Symbol, String), T.untyped]),
|
54
|
+
# options: T.nilable(T::Hash[T.any(Symbol), T.untyped]),
|
55
|
+
# ).returns(T.any(Entry, Asset))}
|
56
|
+
def find_by(content_type:, filter: nil, options: nil)
|
57
|
+
raise NotImplementedError, "#{self.class} does not implement #find_by"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Finds all entries of the given content type. A content type is required.
|
61
|
+
#
|
62
|
+
# Subclasses may override this to provide their own query implementation,
|
63
|
+
# or else override #execute to run the query after it has been parsed.
|
64
|
+
#
|
65
|
+
# @param [String] content_type The ID of the content type to search for.
|
66
|
+
# @param [Hash] options An optional set of additional parameters to the query
|
67
|
+
# defining for example include depth. Not all store implementations respect all options.
|
68
|
+
# @return [Query] A query object that exposes methods to apply filters.
|
69
|
+
# @see WCC::Contentful::Store::Query::Interface
|
70
|
+
# sig {abstract.params(
|
71
|
+
# content_type: String,
|
72
|
+
# filter: T.nilable(T::Hash[T.any(Symbol, String), T.untyped]),
|
73
|
+
# options: T.nilable(T::Hash[T.any(Symbol), T.untyped]),
|
74
|
+
# ).returns(WCC::Contentful::Store::Query::Interface)}
|
75
|
+
def find_all(content_type:, options: nil)
|
76
|
+
raise NotImplementedError, "#{self.class} does not implement #find_all"
|
77
|
+
end
|
78
|
+
|
79
|
+
INTERFACE_METHODS = WCC::Contentful::Store::Interface.instance_methods - Module.instance_methods
|
80
|
+
end
|
81
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
82
|
+
end
|
@@ -1,6 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative 'instrumentation'
|
4
|
+
|
3
5
|
module WCC::Contentful::Store
|
6
|
+
# The MemoryStore is the most naiive store implementation and a good reference
|
7
|
+
# point for more useful implementations. It only implements equality queries
|
8
|
+
# and does not support querying through an association.
|
4
9
|
class MemoryStore < Base
|
5
10
|
def initialize
|
6
11
|
super
|
@@ -33,41 +38,39 @@ module WCC::Contentful::Store
|
|
33
38
|
end
|
34
39
|
end
|
35
40
|
|
36
|
-
def
|
41
|
+
def execute(query)
|
37
42
|
relation = mutex.with_read_lock { @hash.values }
|
38
43
|
|
44
|
+
# relation is an enumerable that we apply conditions to in the form of
|
45
|
+
# Enumerable#select and Enumerable#reject.
|
39
46
|
relation =
|
40
|
-
relation.reject do |v|
|
47
|
+
relation.lazy.reject do |v|
|
41
48
|
value_content_type = v.try(:dig, 'sys', 'contentType', 'sys', 'id')
|
42
|
-
|
49
|
+
if query.content_type == 'Asset'
|
50
|
+
!value_content_type.nil?
|
51
|
+
else
|
52
|
+
value_content_type != query.content_type
|
53
|
+
end
|
43
54
|
end
|
44
|
-
Query.new(self, relation, options)
|
45
|
-
end
|
46
55
|
|
47
|
-
|
48
|
-
|
49
|
-
|
56
|
+
# For each condition, we apply a new Enumerable#select with a block that
|
57
|
+
# enforces the condition.
|
58
|
+
query.conditions.reduce(relation) do |memo, condition|
|
59
|
+
memo.select do |entry|
|
60
|
+
# Our naiive implementation only supports equality operator
|
61
|
+
raise ArgumentError, "Operator #{condition.op} not supported" unless condition.op == :eq
|
50
62
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
def initialize(store, relation, options = nil)
|
55
|
-
super(store)
|
56
|
-
@relation = relation
|
57
|
-
@options = options || {}
|
58
|
-
end
|
63
|
+
# The condition's path tells us where to find the value in the JSON object
|
64
|
+
val = entry.dig(*condition.path)
|
59
65
|
|
60
|
-
|
61
|
-
|
62
|
-
locale ||= 'en-US'
|
63
|
-
Query.new(@store, @relation.select do |v|
|
64
|
-
val = v.dig('fields', field, locale)
|
66
|
+
# For arrays, equality is defined as does the array include the expected value.
|
67
|
+
# See https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters/array-equality-inequality
|
65
68
|
if val.is_a? Array
|
66
|
-
val.include?(expected)
|
69
|
+
val.include?(condition.expected)
|
67
70
|
else
|
68
|
-
val == expected
|
71
|
+
val == condition.expected
|
69
72
|
end
|
70
|
-
end
|
73
|
+
end
|
71
74
|
end
|
72
75
|
end
|
73
76
|
end
|
@@ -1,178 +1,345 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
gem 'pg', '~> 1.0'
|
4
|
+
gem 'connection_pool', '~> 2.2'
|
4
5
|
require 'pg'
|
6
|
+
require 'connection_pool'
|
7
|
+
require_relative 'instrumentation'
|
5
8
|
|
6
9
|
module WCC::Contentful::Store
|
10
|
+
# Implements the store interface where all Contentful entries are stored in a
|
11
|
+
# JSONB table.
|
7
12
|
class PostgresStore < Base
|
8
|
-
|
13
|
+
include WCC::Contentful::Instrumentation
|
14
|
+
|
15
|
+
delegate :each, to: :to_enum
|
16
|
+
|
17
|
+
attr_reader :connection_pool
|
18
|
+
attr_accessor :logger
|
19
|
+
|
20
|
+
def initialize(_config = nil, connection_options = nil, pool_options = nil)
|
9
21
|
super()
|
22
|
+
@schema_ensured = false
|
10
23
|
connection_options ||= { dbname: 'postgres' }
|
11
|
-
|
12
|
-
PostgresStore.
|
24
|
+
pool_options ||= {}
|
25
|
+
@connection_pool = PostgresStore.build_connection_pool(connection_options, pool_options)
|
26
|
+
@dirty = false
|
13
27
|
end
|
14
28
|
|
15
29
|
def set(key, value)
|
16
30
|
ensure_hash value
|
17
|
-
result =
|
18
|
-
|
31
|
+
result =
|
32
|
+
@connection_pool.with do |conn|
|
33
|
+
conn.exec_prepared('upsert_entry', [
|
34
|
+
key,
|
35
|
+
value.to_json,
|
36
|
+
quote_array(extract_links(value))
|
37
|
+
])
|
38
|
+
end
|
39
|
+
|
40
|
+
previous_value =
|
41
|
+
if result.num_tuples == 0
|
42
|
+
nil
|
43
|
+
else
|
44
|
+
val = result.getvalue(0, 0)
|
45
|
+
JSON.parse(val) if val
|
46
|
+
end
|
47
|
+
|
48
|
+
if views_need_update?(value, previous_value)
|
49
|
+
# mark dirty - we need to refresh the materialized view
|
50
|
+
unless mutex.with_read_lock { @dirty }
|
51
|
+
_instrument 'mark_dirty'
|
52
|
+
mutex.with_write_lock { @dirty = true }
|
53
|
+
end
|
54
|
+
end
|
19
55
|
|
20
|
-
|
21
|
-
JSON.parse(val) if val
|
56
|
+
previous_value
|
22
57
|
end
|
23
58
|
|
24
59
|
def keys
|
25
|
-
result = @conn.exec_prepared('select_ids')
|
60
|
+
result = @connection_pool.with { |conn| conn.exec_prepared('select_ids') }
|
26
61
|
arr = []
|
27
62
|
result.each { |r| arr << r['id'].strip }
|
28
63
|
arr
|
64
|
+
rescue PG::ConnectionBad
|
65
|
+
[]
|
29
66
|
end
|
30
67
|
|
31
68
|
def delete(key)
|
32
|
-
result = @conn.exec_prepared('delete_by_id', [key])
|
69
|
+
result = @connection_pool.with { |conn| conn.exec_prepared('delete_by_id', [key]) }
|
33
70
|
return if result.num_tuples == 0
|
34
71
|
|
35
72
|
JSON.parse(result.getvalue(0, 1))
|
36
73
|
end
|
37
74
|
|
38
75
|
def find(key, **_options)
|
39
|
-
result = @conn.exec_prepared('select_entry', [key])
|
76
|
+
result = @connection_pool.with { |conn| conn.exec_prepared('select_entry', [key]) }
|
40
77
|
return if result.num_tuples == 0
|
41
78
|
|
42
79
|
JSON.parse(result.getvalue(0, 1))
|
80
|
+
rescue PG::ConnectionBad
|
81
|
+
nil
|
43
82
|
end
|
44
83
|
|
45
84
|
def find_all(content_type:, options: nil)
|
46
|
-
statement = "WHERE data->'sys'->'contentType'->'sys'->>'id' = $1"
|
47
85
|
Query.new(
|
48
86
|
self,
|
49
|
-
|
50
|
-
|
51
|
-
[content_type],
|
52
|
-
options
|
87
|
+
content_type: content_type,
|
88
|
+
options: options
|
53
89
|
)
|
54
90
|
end
|
55
91
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
92
|
+
def exec_query(statement, params = [])
|
93
|
+
if mutex.with_read_lock { @dirty }
|
94
|
+
was_dirty =
|
95
|
+
mutex.with_write_lock do
|
96
|
+
was_dirty = @dirty
|
97
|
+
@dirty = false
|
98
|
+
was_dirty
|
99
|
+
end
|
100
|
+
|
101
|
+
if was_dirty
|
102
|
+
_instrument 'refresh_views' do
|
103
|
+
@connection_pool.with { |conn| conn.exec_prepared('refresh_views_concurrently') }
|
104
|
+
end
|
105
|
+
end
|
64
106
|
end
|
65
107
|
|
66
|
-
|
67
|
-
|
68
|
-
|
108
|
+
logger&.debug('[PostgresStore] ' + statement + "\n" + params.inspect)
|
109
|
+
_instrument 'exec' do
|
110
|
+
@connection_pool.with { |conn| conn.exec(statement, params) }
|
111
|
+
end
|
112
|
+
end
|
69
113
|
|
70
|
-
|
114
|
+
private
|
71
115
|
|
72
|
-
|
73
|
-
|
116
|
+
def extract_links(entry)
|
117
|
+
return [] unless fields = entry && entry['fields']
|
74
118
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
119
|
+
links =
|
120
|
+
fields.flat_map do |_f, locale_hash|
|
121
|
+
locale_hash&.flat_map do |_locale, value|
|
122
|
+
if value.is_a? Array
|
123
|
+
value.map { |val| val.dig('sys', 'id') if link?(val) }
|
124
|
+
elsif link?(value)
|
125
|
+
value.dig('sys', 'id')
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
83
129
|
|
130
|
+
links.compact
|
131
|
+
end
|
132
|
+
|
133
|
+
def link?(value)
|
134
|
+
value.is_a?(Hash) && value.dig('sys', 'type') == 'Link'
|
135
|
+
end
|
136
|
+
|
137
|
+
def quote_array(arr)
|
138
|
+
return unless arr
|
139
|
+
|
140
|
+
encoder = PG::TextEncoder::Array.new
|
141
|
+
encoder.encode(arr)
|
142
|
+
end
|
143
|
+
|
144
|
+
def views_need_update?(value, previous_value)
|
145
|
+
# contentful_raw_includes_ids_jointable needs update if any links change
|
146
|
+
return true if extract_links(value) != extract_links(previous_value)
|
147
|
+
end
|
148
|
+
|
149
|
+
class Query < WCC::Contentful::Store::Query
|
84
150
|
def count
|
85
151
|
return @count if @count
|
86
152
|
|
87
|
-
statement = 'SELECT count(*)
|
88
|
-
result =
|
153
|
+
statement, params = finalize_statement('SELECT count(*)')
|
154
|
+
result = store.exec_query(statement, params)
|
89
155
|
@count = result.getvalue(0, 0).to_i
|
90
156
|
end
|
91
157
|
|
92
158
|
def first
|
93
159
|
return @first if @first
|
94
160
|
|
95
|
-
statement = 'SELECT
|
96
|
-
result =
|
161
|
+
statement, params = finalize_statement('SELECT t.*', ' LIMIT 1', depth: @options[:include])
|
162
|
+
result = store.exec_query(statement, params)
|
97
163
|
return if result.num_tuples == 0
|
98
164
|
|
99
|
-
|
100
|
-
|
101
|
-
@options[:include]
|
102
|
-
)
|
103
|
-
end
|
104
|
-
|
105
|
-
def map
|
106
|
-
arr = []
|
107
|
-
resolve.each do |row|
|
108
|
-
arr << yield(
|
109
|
-
resolve_includes(
|
110
|
-
JSON.parse(row['data']),
|
111
|
-
@options[:include]
|
112
|
-
)
|
113
|
-
)
|
114
|
-
end
|
115
|
-
arr
|
116
|
-
end
|
165
|
+
row = result.first
|
166
|
+
entry = JSON.parse(row['data'])
|
117
167
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
arr <<
|
122
|
-
resolve_includes(
|
123
|
-
JSON.parse(row['data']),
|
124
|
-
@options[:include]
|
125
|
-
)
|
168
|
+
if @options[:include] && @options[:include] > 0
|
169
|
+
includes = decode_includes(row['includes'])
|
170
|
+
entry = resolve_includes([entry, includes], @options[:include])
|
126
171
|
end
|
127
|
-
|
172
|
+
entry
|
128
173
|
end
|
129
174
|
|
130
|
-
|
175
|
+
def result_set
|
176
|
+
return @result_set if @result_set
|
177
|
+
|
178
|
+
statement, params = finalize_statement('SELECT t.*', depth: @options[:include])
|
179
|
+
@result_set =
|
180
|
+
store.exec_query(statement, params)
|
181
|
+
.lazy.map do |row|
|
182
|
+
entry = JSON.parse(row['data'])
|
183
|
+
includes =
|
184
|
+
(decode_includes(row['includes']) if @options[:include] && @options[:include] > 0)
|
185
|
+
|
186
|
+
[entry, includes]
|
187
|
+
end
|
188
|
+
rescue PG::ConnectionBad
|
189
|
+
[]
|
190
|
+
end
|
131
191
|
|
132
192
|
private
|
133
193
|
|
134
|
-
def
|
135
|
-
return
|
194
|
+
def decode_includes(includes)
|
195
|
+
return {} unless includes
|
136
196
|
|
137
|
-
|
138
|
-
|
197
|
+
decoder = PG::TextDecoder::Array.new
|
198
|
+
decoder.decode(includes)
|
199
|
+
.map { |e| JSON.parse(e) }
|
200
|
+
.each_with_object({}) do |entry, h|
|
201
|
+
h[entry.dig('sys', 'id')] = entry
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def finalize_statement(select_statement, limit_statement = nil, depth: nil)
|
206
|
+
statement =
|
207
|
+
if content_type == 'Asset'
|
208
|
+
"WHERE t.data->'sys'->>'type' = $1"
|
209
|
+
else
|
210
|
+
"WHERE t.data->'sys'->'contentType'->'sys'->>'id' = $1"
|
211
|
+
end
|
212
|
+
params = [content_type]
|
213
|
+
joins = []
|
214
|
+
|
215
|
+
statement =
|
216
|
+
conditions.reduce(statement) do |memo, condition|
|
217
|
+
raise ArgumentError, "Operator #{condition.op} not supported" unless condition.op == :eq
|
218
|
+
|
219
|
+
if condition.path_tuples.length == 1
|
220
|
+
memo + _eq(condition.path, condition.expected, params)
|
221
|
+
else
|
222
|
+
join_path, expectation_path = condition.path_tuples
|
223
|
+
memo + _join(join_path, expectation_path, condition.expected, params, joins)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
table = 'contentful_raw'
|
228
|
+
if depth && depth > 0
|
229
|
+
table = 'contentful_raw_includes'
|
230
|
+
select_statement += ', t.includes'
|
231
|
+
end
|
232
|
+
|
233
|
+
statement =
|
234
|
+
select_statement +
|
235
|
+
" FROM #{table} AS t \n" +
|
236
|
+
joins.join("\n") + "\n" +
|
237
|
+
statement +
|
238
|
+
(limit_statement || '')
|
239
|
+
|
240
|
+
[statement, params]
|
241
|
+
end
|
242
|
+
|
243
|
+
def _eq(path, expected, params)
|
244
|
+
return " AND t.id = $#{push_param(expected, params)}" if path == %w[sys id]
|
245
|
+
|
246
|
+
if path[3] == 'sys'
|
247
|
+
# the path can be either an array or a singular json obj, and we have to dig
|
248
|
+
# into it to detect whether it contains `{ "sys": { "id" => expected } }`
|
249
|
+
expected = { 'sys' => { path[4] => expected } }.to_json
|
250
|
+
return ' AND fn_contentful_jsonb_any_to_jsonb_array(t.data->' \
|
251
|
+
"#{quote_parameter_path(path.take(3))}) @> " \
|
252
|
+
"jsonb_build_array($#{push_param(expected, params)}::jsonb)"
|
253
|
+
end
|
254
|
+
|
255
|
+
" AND t.data->#{quote_parameter_path(path)}" \
|
256
|
+
" ? $#{push_param(expected, params)}::text"
|
139
257
|
end
|
140
258
|
|
141
259
|
def push_param(param, params)
|
142
260
|
params << param
|
143
261
|
params.length
|
144
262
|
end
|
263
|
+
|
264
|
+
def quote_parameter_path(path)
|
265
|
+
path.map { |p| "'#{p}'" }.join('->')
|
266
|
+
end
|
267
|
+
|
268
|
+
def _join(join_path, expectation_path, expected, params, joins)
|
269
|
+
# join back to the table using the links column (join_table_alias becomes s0, s1, s2)
|
270
|
+
# this is faster because of the index
|
271
|
+
join_table_alias = push_join(join_path, joins)
|
272
|
+
|
273
|
+
# then apply the where clauses:
|
274
|
+
# 1. that the joined entry has the data at the appropriate path
|
275
|
+
# 2. that the entry joining to the other entry actually links at this path and not another
|
276
|
+
<<~WHERE_CLAUSE
|
277
|
+
AND #{join_table_alias}.data->#{quote_parameter_path(expectation_path)} ? $#{push_param(expected, params)}::text
|
278
|
+
AND exists (select 1 from jsonb_array_elements(fn_contentful_jsonb_any_to_jsonb_array(t.data->#{quote_parameter_path(join_path)})) as link where link->'sys'->'id' ? #{join_table_alias}.id)
|
279
|
+
WHERE_CLAUSE
|
280
|
+
end
|
281
|
+
|
282
|
+
def push_join(_path, joins)
|
283
|
+
table_alias = "s#{joins.length}"
|
284
|
+
joins << "JOIN contentful_raw AS #{table_alias} ON " \
|
285
|
+
"#{table_alias}.id=ANY(t.links)"
|
286
|
+
table_alias
|
287
|
+
end
|
145
288
|
end
|
146
289
|
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
)
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
SELECT data FROM contentful_raw WHERE id = _id INTO prev;
|
162
|
-
INSERT INTO contentful_raw (id, data) values (_id, _data)
|
163
|
-
ON CONFLICT (id) DO
|
164
|
-
UPDATE
|
165
|
-
SET data = _data;
|
166
|
-
RETURN prev;
|
167
|
-
END;
|
168
|
-
$$ LANGUAGE 'plpgsql';
|
169
|
-
HEREDOC
|
170
|
-
)
|
290
|
+
EXPECTED_VERSION = 2
|
291
|
+
|
292
|
+
class << self
|
293
|
+
def prepare_statements(conn)
|
294
|
+
conn.prepare('upsert_entry', 'SELECT * FROM fn_contentful_upsert_entry($1,$2,$3)')
|
295
|
+
conn.prepare('select_entry', 'SELECT * FROM contentful_raw WHERE id = $1')
|
296
|
+
conn.prepare('select_ids', 'SELECT id FROM contentful_raw')
|
297
|
+
conn.prepare('delete_by_id', 'DELETE FROM contentful_raw WHERE id = $1 RETURNING *')
|
298
|
+
conn.prepare('refresh_views_concurrently',
|
299
|
+
'REFRESH MATERIALIZED VIEW CONCURRENTLY contentful_raw_includes_ids_jointable')
|
300
|
+
end
|
301
|
+
|
302
|
+
# This is intentionally a class var so that all subclasses share the same mutex
|
303
|
+
@@schema_mutex = Mutex.new # rubocop:disable Style/ClassVars
|
171
304
|
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
305
|
+
def build_connection_pool(connection_options, pool_options)
|
306
|
+
ConnectionPool.new(pool_options) do
|
307
|
+
PG.connect(connection_options).tap do |conn|
|
308
|
+
unless schema_ensured?(conn)
|
309
|
+
@@schema_mutex.synchronize do
|
310
|
+
ensure_schema(conn) unless schema_ensured?(conn)
|
311
|
+
end
|
312
|
+
end
|
313
|
+
prepare_statements(conn)
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def schema_ensured?(conn)
|
319
|
+
result = conn.exec('SELECT version FROM wcc_contentful_schema_version' \
|
320
|
+
' ORDER BY version DESC LIMIT 1')
|
321
|
+
return false if result.num_tuples == 0
|
322
|
+
|
323
|
+
result[0]['version'].to_i >= EXPECTED_VERSION
|
324
|
+
rescue PG::UndefinedTable
|
325
|
+
# need to run v1 schema migration
|
326
|
+
false
|
327
|
+
end
|
328
|
+
|
329
|
+
def ensure_schema(conn)
|
330
|
+
result =
|
331
|
+
begin
|
332
|
+
conn.exec('SELECT version FROM wcc_contentful_schema_version' \
|
333
|
+
' ORDER BY version DESC')
|
334
|
+
rescue PG::UndefinedTable
|
335
|
+
[]
|
336
|
+
end
|
337
|
+
1.upto(EXPECTED_VERSION).each do |version_num|
|
338
|
+
next if result.find { |row| row['version'].to_s == version_num.to_s }
|
339
|
+
|
340
|
+
conn.exec(File.read(File.join(__dir__, "postgres_store/schema_#{version_num}.sql")))
|
341
|
+
end
|
342
|
+
end
|
176
343
|
end
|
177
344
|
end
|
178
345
|
end
|