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.
Files changed (99) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +1 -1
  3. data/Guardfile +43 -0
  4. data/README.md +161 -11
  5. data/Rakefile +3 -6
  6. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  7. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  8. data/bin/console +4 -3
  9. data/bin/rails +2 -0
  10. data/config/routes.rb +1 -1
  11. data/doc +1 -0
  12. data/lib/tasks/download_schema.rake +12 -0
  13. data/lib/wcc/contentful.rb +69 -45
  14. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  15. data/lib/wcc/contentful/configuration.rb +177 -46
  16. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  17. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  18. data/lib/wcc/contentful/engine.rb +33 -14
  19. data/lib/wcc/contentful/event.rb +171 -0
  20. data/lib/wcc/contentful/events.rb +41 -0
  21. data/lib/wcc/contentful/exceptions.rb +3 -33
  22. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  23. data/lib/wcc/contentful/instrumentation.rb +31 -0
  24. data/lib/wcc/contentful/link.rb +28 -0
  25. data/lib/wcc/contentful/link_visitor.rb +122 -0
  26. data/lib/wcc/contentful/middleware.rb +7 -0
  27. data/lib/wcc/contentful/middleware/store.rb +158 -0
  28. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  29. data/lib/wcc/contentful/model.rb +37 -4
  30. data/lib/wcc/contentful/model_builder.rb +1 -0
  31. data/lib/wcc/contentful/model_methods.rb +40 -15
  32. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  33. data/lib/wcc/contentful/rake.rb +4 -0
  34. data/lib/wcc/contentful/rspec.rb +46 -0
  35. data/lib/wcc/contentful/services.rb +61 -27
  36. data/lib/wcc/contentful/simple_client.rb +81 -25
  37. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  38. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  39. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  40. data/lib/wcc/contentful/store.rb +7 -66
  41. data/lib/wcc/contentful/store/README.md +85 -0
  42. data/lib/wcc/contentful/store/base.rb +34 -119
  43. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  44. data/lib/wcc/contentful/store/factory.rb +186 -0
  45. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  46. data/lib/wcc/contentful/store/interface.rb +82 -0
  47. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  48. data/lib/wcc/contentful/store/postgres_store.rb +268 -101
  49. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  50. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  51. data/lib/wcc/contentful/store/query.rb +246 -0
  52. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  53. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  54. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  55. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  56. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  57. data/lib/wcc/contentful/sync_engine.rb +181 -0
  58. data/lib/wcc/contentful/test.rb +7 -0
  59. data/lib/wcc/contentful/test/attributes.rb +56 -0
  60. data/lib/wcc/contentful/test/double.rb +76 -0
  61. data/lib/wcc/contentful/test/factory.rb +101 -0
  62. data/lib/wcc/contentful/version.rb +1 -1
  63. data/wcc-contentful.gemspec +28 -14
  64. metadata +248 -152
  65. data/.circleci/config.yml +0 -51
  66. data/.gitignore +0 -26
  67. data/.rubocop.yml +0 -242
  68. data/.rubocop_todo.yml +0 -19
  69. data/.travis.yml +0 -5
  70. data/CHANGELOG.md +0 -180
  71. data/CODE_OF_CONDUCT.md +0 -74
  72. data/Gemfile +0 -8
  73. data/LICENSE.txt +0 -21
  74. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  75. data/lib/generators/wcc/USAGE +0 -24
  76. data/lib/generators/wcc/model_generator.rb +0 -90
  77. data/lib/generators/wcc/templates/.keep +0 -0
  78. data/lib/generators/wcc/templates/Procfile +0 -3
  79. data/lib/generators/wcc/templates/contentful_shell_wrapper +0 -385
  80. data/lib/generators/wcc/templates/menu/generated_add_menus.ts +0 -192
  81. data/lib/generators/wcc/templates/menu/models/menu.rb +0 -23
  82. data/lib/generators/wcc/templates/menu/models/menu_button.rb +0 -23
  83. data/lib/generators/wcc/templates/page/generated_add_pages.ts +0 -50
  84. data/lib/generators/wcc/templates/page/models/page.rb +0 -23
  85. data/lib/generators/wcc/templates/release +0 -9
  86. data/lib/generators/wcc/templates/wcc_contentful.rb +0 -17
  87. data/lib/wcc/contentful/client_ext.rb +0 -28
  88. data/lib/wcc/contentful/graphql.rb +0 -14
  89. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  90. data/lib/wcc/contentful/graphql/types.rb +0 -54
  91. data/lib/wcc/contentful/model/dropdown_menu.rb +0 -7
  92. data/lib/wcc/contentful/model/menu.rb +0 -6
  93. data/lib/wcc/contentful/model/menu_button.rb +0 -16
  94. data/lib/wcc/contentful/model/page.rb +0 -8
  95. data/lib/wcc/contentful/model/redirect.rb +0 -19
  96. data/lib/wcc/contentful/model_validators.rb +0 -121
  97. data/lib/wcc/contentful/model_validators/dsl.rb +0 -166
  98. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  99. 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 find_all(content_type:, options: nil)
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
- value_content_type.nil? || value_content_type != content_type
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
- class Query < Base::Query
48
- def result
49
- return @relation.dup unless @options[:include]
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
- @relation.map { |e| resolve_includes(e, @options[:include]) }
52
- end
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
- def eq(field, expected, context = nil)
61
- locale = context[:locale] if context.present?
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, @options)
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
- def initialize(_config = nil, connection_options = nil)
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
- @conn = PG.connect(connection_options)
12
- PostgresStore.ensure_schema(@conn)
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 = @conn.exec_prepared('upsert_entry', [key, value.to_json])
18
- return if result.num_tuples == 0
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
- val = result.getvalue(0, 0)
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
- @conn,
50
- statement,
51
- [content_type],
52
- options
87
+ content_type: content_type,
88
+ options: options
53
89
  )
54
90
  end
55
91
 
56
- class Query < Base::Query
57
- def initialize(store, conn, statement = nil, params = nil, options = nil)
58
- super(store)
59
- @conn = conn
60
- @statement = statement ||
61
- "WHERE data->'sys'->>'id' IS NOT NULL"
62
- @params = params || []
63
- @options = options || {}
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
- def eq(field, expected, context = nil)
67
- locale = context[:locale] if context.present?
68
- locale ||= 'en-US'
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
- params = @params.dup
114
+ private
71
115
 
72
- statement = @statement + " AND data->'fields'->$#{push_param(field, params)}" \
73
- "->$#{push_param(locale, params)} ? $#{push_param(expected, params)}"
116
+ def extract_links(entry)
117
+ return [] unless fields = entry && entry['fields']
74
118
 
75
- Query.new(
76
- @store,
77
- @conn,
78
- statement,
79
- params,
80
- @options
81
- )
82
- end
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(*) FROM contentful_raw ' + @statement
88
- result = @conn.exec(statement, @params)
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 * FROM contentful_raw ' + @statement + ' LIMIT 1'
96
- result = @conn.exec(statement, @params)
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
- resolve_includes(
100
- JSON.parse(result.getvalue(0, 1)),
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
- def result
119
- arr = []
120
- resolve.each do |row|
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
- arr
172
+ entry
128
173
  end
129
174
 
130
- # TODO: override resolve_includes to make it more efficient
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 resolve
135
- return @resolved if @resolved
194
+ def decode_includes(includes)
195
+ return {} unless includes
136
196
 
137
- statement = 'SELECT * FROM contentful_raw ' + @statement
138
- @resolved = @conn.exec(statement, @params)
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
- def self.ensure_schema(conn)
148
- conn.exec(<<~HEREDOC
149
- CREATE TABLE IF NOT EXISTS contentful_raw (
150
- id varchar PRIMARY KEY,
151
- data jsonb
152
- );
153
- CREATE INDEX IF NOT EXISTS contentful_raw_value_type ON contentful_raw ((data->'sys'->>'type'));
154
- CREATE INDEX IF NOT EXISTS contentful_raw_value_content_type ON contentful_raw ((data->'sys'->'contentType'->'sys'->>'id'));
155
-
156
- DROP FUNCTION IF EXISTS "upsert_entry"(_id varchar, _data jsonb);
157
- CREATE FUNCTION "upsert_entry"(_id varchar, _data jsonb) RETURNS jsonb AS $$
158
- DECLARE
159
- prev jsonb;
160
- BEGIN
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
- conn.prepare('upsert_entry', 'SELECT * FROM upsert_entry($1,$2)')
173
- conn.prepare('select_entry', 'SELECT * FROM contentful_raw WHERE id = $1')
174
- conn.prepare('select_ids', 'SELECT id FROM contentful_raw')
175
- conn.prepare('delete_by_id', 'DELETE FROM contentful_raw WHERE id = $1 RETURNING *')
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