wcc-contentful 0.4.0.pre.beta → 1.0.0

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 (67) hide show
  1. checksums.yaml +5 -5
  2. data/Guardfile +43 -0
  3. data/README.md +205 -17
  4. data/Rakefile +5 -0
  5. data/app/controllers/wcc/contentful/webhook_controller.rb +25 -24
  6. data/app/jobs/wcc/contentful/webhook_enable_job.rb +36 -2
  7. data/config/routes.rb +1 -1
  8. data/doc-static/wcc-contentful.png +0 -0
  9. data/lib/tasks/download_schema.rake +12 -0
  10. data/lib/wcc/contentful.rb +70 -16
  11. data/lib/wcc/contentful/active_record_shim.rb +72 -0
  12. data/lib/wcc/contentful/configuration.rb +177 -46
  13. data/lib/wcc/contentful/content_type_indexer.rb +14 -0
  14. data/lib/wcc/contentful/downloads_schema.rb +112 -0
  15. data/lib/wcc/contentful/engine.rb +33 -14
  16. data/lib/wcc/contentful/event.rb +171 -0
  17. data/lib/wcc/contentful/events.rb +41 -0
  18. data/lib/wcc/contentful/exceptions.rb +3 -0
  19. data/lib/wcc/contentful/indexed_representation.rb +2 -2
  20. data/lib/wcc/contentful/instrumentation.rb +31 -0
  21. data/lib/wcc/contentful/link.rb +28 -0
  22. data/lib/wcc/contentful/link_visitor.rb +122 -0
  23. data/lib/wcc/contentful/middleware.rb +7 -0
  24. data/lib/wcc/contentful/middleware/store.rb +158 -0
  25. data/lib/wcc/contentful/middleware/store/caching_middleware.rb +114 -0
  26. data/lib/wcc/contentful/model.rb +37 -3
  27. data/lib/wcc/contentful/model_builder.rb +1 -0
  28. data/lib/wcc/contentful/model_methods.rb +40 -15
  29. data/lib/wcc/contentful/model_singleton_methods.rb +47 -30
  30. data/lib/wcc/contentful/rake.rb +4 -0
  31. data/lib/wcc/contentful/rspec.rb +13 -8
  32. data/lib/wcc/contentful/services.rb +61 -27
  33. data/lib/wcc/contentful/simple_client.rb +81 -25
  34. data/lib/wcc/contentful/simple_client/management.rb +43 -10
  35. data/lib/wcc/contentful/simple_client/response.rb +61 -22
  36. data/lib/wcc/contentful/simple_client/typhoeus_adapter.rb +17 -17
  37. data/lib/wcc/contentful/store.rb +7 -66
  38. data/lib/wcc/contentful/store/README.md +85 -0
  39. data/lib/wcc/contentful/store/base.rb +34 -119
  40. data/lib/wcc/contentful/store/cdn_adapter.rb +71 -12
  41. data/lib/wcc/contentful/store/factory.rb +186 -0
  42. data/lib/wcc/contentful/store/instrumentation.rb +55 -0
  43. data/lib/wcc/contentful/store/interface.rb +82 -0
  44. data/lib/wcc/contentful/store/memory_store.rb +27 -24
  45. data/lib/wcc/contentful/store/postgres_store.rb +253 -107
  46. data/lib/wcc/contentful/store/postgres_store/schema_1.sql +73 -0
  47. data/lib/wcc/contentful/store/postgres_store/schema_2.sql +21 -0
  48. data/lib/wcc/contentful/store/query.rb +246 -0
  49. data/lib/wcc/contentful/store/query/interface.rb +63 -0
  50. data/lib/wcc/contentful/store/rspec_examples.rb +48 -0
  51. data/lib/wcc/contentful/store/rspec_examples/basic_store.rb +629 -0
  52. data/lib/wcc/contentful/store/rspec_examples/include_param.rb +283 -0
  53. data/lib/wcc/contentful/store/rspec_examples/nested_queries.rb +342 -0
  54. data/lib/wcc/contentful/sync_engine.rb +181 -0
  55. data/lib/wcc/contentful/test/attributes.rb +17 -5
  56. data/lib/wcc/contentful/test/factory.rb +22 -46
  57. data/lib/wcc/contentful/version.rb +1 -1
  58. data/wcc-contentful.gemspec +22 -11
  59. metadata +295 -144
  60. data/Gemfile +0 -6
  61. data/app/jobs/wcc/contentful/delayed_sync_job.rb +0 -63
  62. data/lib/wcc/contentful/client_ext.rb +0 -28
  63. data/lib/wcc/contentful/graphql.rb +0 -14
  64. data/lib/wcc/contentful/graphql/builder.rb +0 -177
  65. data/lib/wcc/contentful/graphql/types.rb +0 -54
  66. data/lib/wcc/contentful/simple_client/http_adapter.rb +0 -24
  67. data/lib/wcc/contentful/store/lazy_cache_store.rb +0 -161
@@ -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
@@ -4,26 +4,56 @@ gem 'pg', '~> 1.0'
4
4
  gem 'connection_pool', '~> 2.2'
5
5
  require 'pg'
6
6
  require 'connection_pool'
7
+ require_relative 'instrumentation'
7
8
 
8
9
  module WCC::Contentful::Store
10
+ # Implements the store interface where all Contentful entries are stored in a
11
+ # JSONB table.
9
12
  class PostgresStore < Base
13
+ include WCC::Contentful::Instrumentation
14
+
15
+ delegate :each, to: :to_enum
16
+
10
17
  attr_reader :connection_pool
18
+ attr_accessor :logger
11
19
 
12
20
  def initialize(_config = nil, connection_options = nil, pool_options = nil)
13
21
  super()
14
22
  @schema_ensured = false
15
23
  connection_options ||= { dbname: 'postgres' }
16
24
  pool_options ||= {}
17
- @connection_pool = build_connection_pool(connection_options, pool_options)
25
+ @connection_pool = PostgresStore.build_connection_pool(connection_options, pool_options)
26
+ @dirty = false
18
27
  end
19
28
 
20
29
  def set(key, value)
21
30
  ensure_hash value
22
- result = @connection_pool.with { |conn| conn.exec_prepared('upsert_entry', [key, value.to_json]) }
23
- 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
24
55
 
25
- val = result.getvalue(0, 0)
26
- JSON.parse(val) if val
56
+ previous_value
27
57
  end
28
58
 
29
59
  def keys
@@ -31,6 +61,8 @@ module WCC::Contentful::Store
31
61
  arr = []
32
62
  result.each { |r| arr << r['id'].strip }
33
63
  arr
64
+ rescue PG::ConnectionBad
65
+ []
34
66
  end
35
67
 
36
68
  def delete(key)
@@ -45,153 +77,267 @@ module WCC::Contentful::Store
45
77
  return if result.num_tuples == 0
46
78
 
47
79
  JSON.parse(result.getvalue(0, 1))
80
+ rescue PG::ConnectionBad
81
+ nil
48
82
  end
49
83
 
50
84
  def find_all(content_type:, options: nil)
51
- statement = "WHERE data->'sys'->'contentType'->'sys'->>'id' = $1"
52
85
  Query.new(
53
86
  self,
54
- @connection_pool,
55
- statement,
56
- [content_type],
57
- options
87
+ content_type: content_type,
88
+ options: options
58
89
  )
59
90
  end
60
91
 
61
- class Query < Base::Query
62
- def initialize(store, connection_pool, statement = nil, params = nil, options = nil)
63
- super(store)
64
- @connection_pool = connection_pool
65
- @statement = statement ||
66
- "WHERE data->'sys'->>'id' IS NOT NULL"
67
- @params = params || []
68
- @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
69
106
  end
70
107
 
71
- def eq(field, expected, context = nil)
72
- locale = context[:locale] if context.present?
73
- 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
74
113
 
75
- params = @params.dup
114
+ private
76
115
 
77
- statement = @statement + " AND data->'fields'->$#{push_param(field, params)}" \
78
- "->$#{push_param(locale, params)} ? $#{push_param(expected, params)}"
116
+ def extract_links(entry)
117
+ return [] unless fields = entry && entry['fields']
118
+
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
79
129
 
80
- Query.new(
81
- @store,
82
- @connection_pool,
83
- statement,
84
- params,
85
- @options
86
- )
87
- end
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
88
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
89
150
  def count
90
151
  return @count if @count
91
152
 
92
- statement = 'SELECT count(*) FROM contentful_raw ' + @statement
93
- result = @connection_pool.with { |conn| conn.exec(statement, @params) }
153
+ statement, params = finalize_statement('SELECT count(*)')
154
+ result = store.exec_query(statement, params)
94
155
  @count = result.getvalue(0, 0).to_i
95
156
  end
96
157
 
97
158
  def first
98
159
  return @first if @first
99
160
 
100
- statement = 'SELECT * FROM contentful_raw ' + @statement + ' LIMIT 1'
101
- result = @connection_pool.with { |conn| conn.exec(statement, @params) }
161
+ statement, params = finalize_statement('SELECT t.*', ' LIMIT 1', depth: @options[:include])
162
+ result = store.exec_query(statement, params)
102
163
  return if result.num_tuples == 0
103
164
 
104
- resolve_includes(
105
- JSON.parse(result.getvalue(0, 1)),
106
- @options[:include]
107
- )
108
- end
109
-
110
- def map
111
- arr = []
112
- resolve.each do |row|
113
- arr << yield(
114
- resolve_includes(
115
- JSON.parse(row['data']),
116
- @options[:include]
117
- )
118
- )
119
- end
120
- arr
121
- end
165
+ row = result.first
166
+ entry = JSON.parse(row['data'])
122
167
 
123
- def result
124
- arr = []
125
- resolve.each do |row|
126
- arr <<
127
- resolve_includes(
128
- JSON.parse(row['data']),
129
- @options[:include]
130
- )
168
+ if @options[:include] && @options[:include] > 0
169
+ includes = decode_includes(row['includes'])
170
+ entry = resolve_includes([entry, includes], @options[:include])
131
171
  end
132
- arr
172
+ entry
133
173
  end
134
174
 
135
- # 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
136
191
 
137
192
  private
138
193
 
139
- def resolve
140
- return @resolved if @resolved
194
+ def decode_includes(includes)
195
+ return {} unless includes
141
196
 
142
- statement = 'SELECT * FROM contentful_raw ' + @statement
143
- @resolved = @connection_pool.with { |conn| 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"
144
257
  end
145
258
 
146
259
  def push_param(param, params)
147
260
  params << param
148
261
  params.length
149
262
  end
150
- end
151
263
 
152
- def self.ensure_schema(conn)
153
- conn.exec(<<~HEREDOC
154
- CREATE TABLE IF NOT EXISTS contentful_raw (
155
- id varchar PRIMARY KEY,
156
- data jsonb
157
- );
158
- CREATE INDEX IF NOT EXISTS contentful_raw_value_type ON contentful_raw ((data->'sys'->>'type'));
159
- CREATE INDEX IF NOT EXISTS contentful_raw_value_content_type ON contentful_raw ((data->'sys'->'contentType'->'sys'->>'id'));
160
-
161
- DROP FUNCTION IF EXISTS "upsert_entry"(_id varchar, _data jsonb);
162
- CREATE FUNCTION "upsert_entry"(_id varchar, _data jsonb) RETURNS jsonb AS $$
163
- DECLARE
164
- prev jsonb;
165
- BEGIN
166
- SELECT data FROM contentful_raw WHERE id = _id INTO prev;
167
- INSERT INTO contentful_raw (id, data) values (_id, _data)
168
- ON CONFLICT (id) DO
169
- UPDATE
170
- SET data = _data;
171
- RETURN prev;
172
- END;
173
- $$ LANGUAGE 'plpgsql';
174
- HEREDOC
175
- )
176
- end
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
177
281
 
178
- def self.prepare_statements(conn)
179
- conn.prepare('upsert_entry', 'SELECT * FROM upsert_entry($1,$2)')
180
- conn.prepare('select_entry', 'SELECT * FROM contentful_raw WHERE id = $1')
181
- conn.prepare('select_ids', 'SELECT id FROM contentful_raw')
182
- conn.prepare('delete_by_id', 'DELETE FROM contentful_raw WHERE id = $1 RETURNING *')
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
183
288
  end
184
289
 
185
- private
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
186
301
 
187
- def build_connection_pool(connection_options, pool_options)
188
- ConnectionPool.new(pool_options) do
189
- PG.connect(connection_options).tap do |conn|
190
- unless @schema_ensured
191
- PostgresStore.ensure_schema(conn)
192
- @schema_ensured = true
302
+ # This is intentionally a class var so that all subclasses share the same mutex
303
+ @@schema_mutex = Mutex.new # rubocop:disable Style/ClassVars
304
+
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)
193
314
  end
194
- PostgresStore.prepare_statements(conn)
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")))
195
341
  end
196
342
  end
197
343
  end