valkyrie-sequel 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 147de175c6560bcb1836553dd0c5c3e94908c5f8
4
+ data.tar.gz: e48b68150de3a5ed8efb8ac4064866db769227ee
5
+ SHA512:
6
+ metadata.gz: 546160cf2ab0d62a4e2196bc8f390510999cc8e2e9e74e6aa3f483eec6c2f236b45103361d37c31f98d84cf4a45fbf4dd98cbe6cf389ad20aefe594eb7326a6f
7
+ data.tar.gz: 396c775f741c4d972e372c5400cd9f23236dac76c983e5d3183553726c745e3bf30be4dbf4dbb17e456d1ee29fbc8eaa551d0e3dc8f7d929e5bddd0629ab688c
@@ -0,0 +1,33 @@
1
+ ---
2
+ version: 2
3
+ jobs:
4
+ build:
5
+ docker:
6
+ - image: circleci/ruby:2.3.7-node-browsers
7
+ environment:
8
+ RAILS_ENV: test
9
+ DB_HOST: localhost
10
+ DB_USERNAME: valkyrie_sequel
11
+ DB_PASSWORD: ""
12
+ DB_DATABASE: "valkyrie_sequel_test"
13
+ - image: postgres:10.3-alpine
14
+ environment:
15
+ POSTGRES_USER: valkyrie_sequel
16
+ POSTGRES_DB: valkyrie_sequel_test
17
+ POSTGRES_PASSWORD: ""
18
+ steps:
19
+ - checkout
20
+ # Restore Cached Dependencies
21
+ - type: cache-restore
22
+ name: Restore bundle cache
23
+ key: valkyrie-sequel-{{ checksum "Gemfile" }}
24
+ # Bundle install dependencies
25
+ - run: bundle install --path vendor/bundle
26
+ # Cache Dependencies
27
+ - type: cache-save
28
+ name: Store bundle cache
29
+ key: valkyrie-sequel-{{ checksum "Gemfile" }}
30
+ paths:
31
+ - vendor/bundle
32
+ - run: bundle exec rubocop
33
+ - run: bundle exec rspec spec
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,22 @@
1
+ inherit_gem:
2
+ bixby: bixby_default.yml
3
+ AllCops:
4
+ DisplayCopNames: true
5
+ TargetRubyVersion: 2.3
6
+ Exclude:
7
+ - 'bin/*'
8
+ - 'db/schema.rb'
9
+ - 'vendor/**/*'
10
+ Naming/FileName:
11
+ Exclude:
12
+ - 'valkyrie-sequel.gemspec'
13
+ - 'Gemfile'
14
+ Metrics/BlockLength:
15
+ Exclude:
16
+ - 'spec/**/*.rb'
17
+ Metrics/ClassLength:
18
+ Exclude:
19
+ - 'lib/valkyrie/sequel/query_service.rb'
20
+ Metrics/ParameterLists:
21
+ Exclude:
22
+ - 'lib/valkyrie/sequel/metadata_adapter.rb'
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.3
5
+ before_install: gem install bundler -v 1.16.1
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ source "https://rubygems.org"
3
+
4
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
5
+
6
+ # Specify your gem's dependencies in valkyrie-sequel.gemspec
7
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright 2018 Princeton University Library
2
+ Additional copyright may be held by others, as reflected in the commit history.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # Valkyrie::Sequel
2
+
3
+ Valkyrie adapter for postgres using [Sequel](https://github.com/jeremyevans/sequel)
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'valkyrie-sequel'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install valkyrie-sequel
20
+
21
+ ## Running Specs
22
+
23
+ 1. Ensure Postgres is installed (`brew install postgresql` on Mac)
24
+ 2. If necessary, provide the environment variables DB_USERNAME, DB_PASSWORD,
25
+ DB_HOST, DB_PORT, and DB_DATABASE to the following commands. This should not be
26
+ necessary on a local development setup, though.
27
+ 3. `rake db:create`
28
+ 4. `rspec spec`
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ require "bundler/gem_tasks"
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
8
+
9
+ namespace :db do
10
+ task :environment do
11
+ require 'valkyrie/sequel'
12
+ require_relative 'spec/support/db_connection_info'
13
+ end
14
+ desc "Create Test Database"
15
+ task create: :environment do
16
+ connection = Sequel.connect(DB_CONNECTION_INFO.merge(adapter: :postgres, database: :postgres))
17
+ begin
18
+ connection.execute "CREATE DATABASE #{DB_CONNECTION_INFO[:database]}"
19
+ puts "Database #{DB_CONNECTION_INFO[:database]} created."
20
+ rescue Sequel::DatabaseError
21
+ puts "Database already exists"
22
+ end
23
+ end
24
+ desc "Drop Test Database"
25
+ task drop: :environment do
26
+ new_connection = Sequel.connect(DB_CONNECTION_INFO.merge(adapter: :postgres, database: :postgres))
27
+ new_connection.execute "DROP DATABASE IF EXISTS #{DB_CONNECTION_INFO[:database]}"
28
+ puts "#{DB_CONNECTION_INFO[:database]} dropped"
29
+ end
30
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "valkyrie/sequel"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
File without changes
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ Sequel.migration do
3
+ up do
4
+ run 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
5
+ create_table :orm_resources do
6
+ column :id, :uuid, default: Sequel.function(:uuid_generate_v4), primary_key: true
7
+ column :metadata, :jsonb, default: '{}', index: { type: :gin }
8
+ String :internal_resource, index: true
9
+ Integer :lock_version, index: true
10
+ DateTime :created_at, index: true, default: ::Sequel::CURRENT_TIMESTAMP
11
+ DateTime :updated_at, index: true
12
+ end
13
+ run 'CREATE INDEX orm_resources_metadata_index_pathops ON orm_resources USING gin (metadata jsonb_path_ops)'
14
+ end
15
+ down do
16
+ drop_table :orm_resources
17
+ run 'DROP EXTENSION "uuid-ossp"'
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ require 'oj'
3
+ module Sequel
4
+ def self.object_to_json(obj, *_args, &_block)
5
+ ::Oj.dump(obj.as_json)
6
+ end
7
+
8
+ def self.parse_json(json)
9
+ Oj.load(json, create_additions: false, mode: :compat)
10
+ end
11
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+ module Valkyrie::Sequel
3
+ require 'valkyrie/sequel/resource_factory'
4
+ require 'valkyrie/sequel/query_service'
5
+ require 'valkyrie/sequel/persister'
6
+ class MetadataAdapter
7
+ attr_reader :connection
8
+ def initialize(connection:)
9
+ @connection = connection.tap do |conn|
10
+ conn.extension(:pg_json)
11
+ conn.extension(:pg_streaming)
12
+ end
13
+ end
14
+
15
+ def persister
16
+ @persister ||= Persister.new(adapter: self)
17
+ end
18
+
19
+ def query_service
20
+ @query_service ||= QueryService.new(adapter: self)
21
+ end
22
+
23
+ def resource_factory
24
+ @resource_factory ||= ResourceFactory.new(adapter: self)
25
+ end
26
+
27
+ def id
28
+ @id ||= begin
29
+ to_hash = "sequel://#{host}:#{port}:#{database}"
30
+ Valkyrie::ID.new(Digest::MD5.hexdigest(to_hash))
31
+ end
32
+ end
33
+
34
+ def perform_migrations!
35
+ Sequel.extension :migration
36
+ Sequel::Migrator.run(connection, "#{__dir__}/../../../db/migrations")
37
+ end
38
+
39
+ def reset_database!
40
+ Sequel.extension :migration
41
+ Sequel::Migrator.run(connection, "#{__dir__}/../../../db/migrations", target: 0)
42
+ perform_migrations!
43
+ end
44
+
45
+ def resources
46
+ connection.from(:orm_resources)
47
+ end
48
+
49
+ private
50
+
51
+ def host
52
+ connection.opts[:host]
53
+ end
54
+
55
+ def port
56
+ connection.opts[:port]
57
+ end
58
+
59
+ def database
60
+ connection.opts[:database]
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+ module Valkyrie::Sequel
3
+ class Persister
4
+ attr_reader :adapter
5
+ delegate :resource_factory, to: :adapter
6
+ delegate :resources, :connection, to: :adapter
7
+ def initialize(adapter:)
8
+ @adapter = adapter
9
+ end
10
+
11
+ def save(resource:)
12
+ object_attributes = resource_factory.from_resource(resource: resource)
13
+ output = create_or_update(resource: resource, attributes: object_attributes)
14
+ resource_factory.to_resource(object: output)
15
+ end
16
+
17
+ def save_all(resources:)
18
+ connection.transaction do
19
+ output = SaveAllPersister::Factory.new(persister: self).for(resources: resources).persist!
20
+ raise Valkyrie::Persistence::StaleObjectError, "One or more resources have been updated by another process." if output.length != resources.length
21
+ output.map do |object|
22
+ resource_factory.to_resource(object: object)
23
+ end
24
+ end
25
+ end
26
+
27
+ class SaveAllPersister
28
+ class Factory
29
+ delegate :adapter, to: :persister
30
+ delegate :resource_factory, to: :adapter
31
+ delegate :resources, to: :adapter
32
+ attr_reader :persister
33
+ def initialize(persister:)
34
+ @persister = persister
35
+ end
36
+
37
+ # Resources have to be handled differently based on whether or not
38
+ # optimistic locking is enabled. Splitting it into two upserts allows
39
+ # for faster save_all while still handling optimistic locking.
40
+ def for(resources:)
41
+ grouped_resources = resources.group_by(&:optimistic_locking_enabled?)
42
+ locked_resources = grouped_resources[true] || []
43
+ unlocked_resources = grouped_resources[false] || []
44
+ CompositePersister.new(
45
+ [
46
+ SaveAllPersister.new(resources: locked_resources, relation: locking_relation, resource_factory: resource_factory),
47
+ SaveAllPersister.new(resources: unlocked_resources, relation: relation, resource_factory: resource_factory)
48
+ ]
49
+ )
50
+ end
51
+
52
+ def relation
53
+ resources.returning.insert_conflict(
54
+ target: :id,
55
+ update: update_branches
56
+ )
57
+ end
58
+
59
+ # Locking relation has an update_where condition.
60
+ def locking_relation
61
+ resources.returning.insert_conflict(
62
+ target: :id,
63
+ update: update_branches,
64
+ update_where: { Sequel[:orm_resources][:lock_version] => Sequel[:excluded][:lock_version] }
65
+ )
66
+ end
67
+
68
+ def update_branches
69
+ {
70
+ metadata: Sequel[:excluded][:metadata],
71
+ internal_resource: Sequel[:excluded][:internal_resource],
72
+ lock_version: Sequel[:excluded][:lock_version] + 1,
73
+ created_at: Sequel[:excluded][:created_at],
74
+ updated_at: Time.now.utc
75
+ }
76
+ end
77
+ end
78
+ attr_reader :resources, :relation, :resource_factory
79
+ def initialize(resources:, relation:, resource_factory:)
80
+ @resources = resources
81
+ @relation = relation
82
+ @resource_factory = resource_factory
83
+ end
84
+
85
+ def persist!
86
+ return [] if resources.empty?
87
+ Array.wrap(
88
+ relation.multi_insert(converted_resources)
89
+ )
90
+ end
91
+
92
+ def converted_resources
93
+ @converted_resources ||= resources.map do |resource|
94
+ output = resource_factory.from_resource(resource: resource)
95
+ output[:lock_version] ||= 0
96
+ output[:created_at] ||= Time.now.utc
97
+ output[:updated_at] ||= Time.now.utc
98
+ output
99
+ end
100
+ end
101
+ class CompositePersister
102
+ attr_reader :persisters
103
+ def initialize(persisters)
104
+ @persisters = persisters
105
+ end
106
+
107
+ def persist!
108
+ persisters.flat_map(&:persist!)
109
+ end
110
+ end
111
+ end
112
+ def delete(resource:)
113
+ resources.where(id: resource.id.to_s).delete
114
+ resource
115
+ end
116
+
117
+ def wipe!
118
+ resources.delete
119
+ end
120
+
121
+ private
122
+
123
+ def create_or_update(resource:, attributes:)
124
+ attributes[:updated_at] = Time.now.utc
125
+ attributes[:created_at] ||= Time.now.utc
126
+ return create(resource: resource, attributes: attributes) unless resource.persisted? && !exists?(id: attributes[:id])
127
+ update(resource: resource, attributes: attributes)
128
+ end
129
+
130
+ def create(resource:, attributes:)
131
+ attributes[:lock_version] = 0 if resource.optimistic_locking_enabled? && resources.columns.include?(:lock_version)
132
+ Array(resources.returning.insert(attributes)).first
133
+ end
134
+
135
+ def update(resource:, attributes:)
136
+ relation = resources.where(id: attributes[:id])
137
+ if resource.optimistic_locking_enabled?
138
+ relation = relation.where(lock_version: attributes[:lock_version]) if attributes[:lock_version]
139
+ attributes[:lock_version] = (Sequel[:lock_version] + 1)
140
+ end
141
+ attributes.delete(:lock_version) if attributes[:lock_version].nil?
142
+ output = relation.returning.update(attributes)
143
+ raise Valkyrie::Persistence::StaleObjectError, "The object #{resource.id} has been updated by another process." if output.blank? && resource.optimistic_locking_enabled?
144
+ Array(output).first
145
+ end
146
+
147
+ def exists?(id:)
148
+ resources.select(1).first(id: id).nil?
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+ module Valkyrie::Sequel
3
+ class QueryService
4
+ ACCEPTABLE_UUID = %r{\A(\{)?([a-fA-F0-9]{4}-?){8}(?(1)\}|)\z}
5
+ attr_reader :adapter
6
+ delegate :resources, :resource_factory, :connection, to: :adapter
7
+ def initialize(adapter:)
8
+ @adapter = adapter
9
+ end
10
+
11
+ def find_all
12
+ resources.use_cursor.lazy.map do |attributes|
13
+ resource_factory.to_resource(object: attributes)
14
+ end
15
+ end
16
+
17
+ def find_by(id:)
18
+ id = Valkyrie::ID.new(id.to_s) if id.is_a?(String)
19
+ validate_id(id)
20
+ raise Valkyrie::Persistence::ObjectNotFoundError unless ACCEPTABLE_UUID.match?(id.to_s)
21
+ attributes = resources.first(id: id.to_s)
22
+ raise Valkyrie::Persistence::ObjectNotFoundError unless attributes
23
+ resource_factory.to_resource(object: attributes)
24
+ end
25
+
26
+ def find_all_of_model(model:)
27
+ resources.where(internal_resource: model.to_s).map do |attributes|
28
+ resource_factory.to_resource(object: attributes)
29
+ end
30
+ end
31
+
32
+ def find_many_by_ids(ids:)
33
+ ids = ids.map do |id|
34
+ id = Valkyrie::ID.new(id.to_s) if id.is_a?(String)
35
+ validate_id(id)
36
+ id.to_s
37
+ end
38
+ ids = ids.select do |id|
39
+ ACCEPTABLE_UUID.match?(id)
40
+ end
41
+
42
+ resources.where(id: ids).map do |attributes|
43
+ resource_factory.to_resource(object: attributes)
44
+ end
45
+ end
46
+
47
+ def find_references_by(resource:, property:)
48
+ return [] if resource.id.blank? || resource[property].blank?
49
+ # only return ordered if needed to avoid performance penalties
50
+ if ordered_property?(resource: resource, property: property)
51
+ run_query(find_ordered_references_query, property.to_s, resource.id.to_s)
52
+ else
53
+ run_query(find_references_query, property.to_s, resource.id.to_s)
54
+ end
55
+ end
56
+
57
+ def find_inverse_references_by(resource: nil, id: nil, property:)
58
+ raise ArgumentError, "Provide resource or id" unless resource || id
59
+ ensure_persisted(resource) if resource
60
+ id ||= resource.id
61
+ internal_array = { property => [id: id.to_s] }
62
+ run_query(find_inverse_references_query, internal_array.to_json)
63
+ end
64
+
65
+ # Find and a record using a Valkyrie ID for an alternate ID, and construct
66
+ # a Valkyrie Resource
67
+ # @param [Valkyrie::ID] alternate_identifier
68
+ # @return [Valkyrie::Resource]
69
+ def find_by_alternate_identifier(alternate_identifier:)
70
+ alternate_identifier = Valkyrie::ID.new(alternate_identifier.to_s) if alternate_identifier.is_a?(String)
71
+ validate_id(alternate_identifier)
72
+ internal_array = { alternate_ids: [{ id: alternate_identifier.to_s }] }
73
+ run_query(find_inverse_references_query, internal_array.to_json).first || raise(Valkyrie::Persistence::ObjectNotFoundError)
74
+ end
75
+
76
+ def find_members(resource:, model: nil)
77
+ return [] if resource.id.blank?
78
+ if model
79
+ run_query(find_members_with_type_query, resource.id.to_s, model.to_s)
80
+ else
81
+ run_query(find_members_query, resource.id.to_s)
82
+ end
83
+ end
84
+
85
+ def find_parents(resource:)
86
+ find_inverse_references_by(resource: resource, property: :member_ids)
87
+ end
88
+
89
+ # Constructs a Valkyrie::Persistence::CustomQueryContainer using this query service
90
+ # @return [Valkyrie::Persistence::CustomQueryContainer]
91
+ def custom_queries
92
+ @custom_queries ||= ::Valkyrie::Persistence::CustomQueryContainer.new(query_service: self)
93
+ end
94
+
95
+ def run_query(query, *args)
96
+ connection[query, *args].map do |result|
97
+ resource_factory.to_resource(object: result)
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # Generate the SQL query for retrieving member resources in PostgreSQL using a
104
+ # resource ID as an argument.
105
+ # @see https://guides.rubyonrails.org/active_record_querying.html#array-conditions
106
+ # @note this uses a CROSS JOIN for all combinations of member IDs with the
107
+ # IDs of their parents
108
+ # @see https://www.postgresql.org/docs/current/static/queries-table-expressions.html#QUERIES-FROM
109
+ # This also uses JSON functions in order to retrieve JSON property values
110
+ # @see https://www.postgresql.org/docs/current/static/functions-json.html
111
+ # @return [String]
112
+ def find_members_query
113
+ <<-SQL
114
+ SELECT member.* FROM orm_resources a,
115
+ jsonb_array_elements(a.metadata->'member_ids') WITH ORDINALITY AS b(member, member_pos)
116
+ JOIN orm_resources member ON (b.member->>'id')::#{id_type} = member.id WHERE a.id = ?
117
+ ORDER BY b.member_pos
118
+ SQL
119
+ end
120
+
121
+ # Generate the SQL query for retrieving member resources in PostgreSQL using a
122
+ # resource ID and resource type as arguments.
123
+ # @see https://guides.rubyonrails.org/active_record_querying.html#array-conditions
124
+ # @note this uses a CROSS JOIN for all combinations of member IDs with the
125
+ # IDs of their parents
126
+ # @see https://www.postgresql.org/docs/current/static/queries-table-expressions.html#QUERIES-FROM
127
+ # This also uses JSON functions in order to retrieve JSON property values
128
+ # @see https://www.postgresql.org/docs/current/static/functions-json.html
129
+ # @return [String]
130
+ def find_members_with_type_query
131
+ <<-SQL
132
+ SELECT member.* FROM orm_resources a,
133
+ jsonb_array_elements(a.metadata->'member_ids') WITH ORDINALITY AS b(member, member_pos)
134
+ JOIN orm_resources member ON (b.member->>'id')::#{id_type} = member.id WHERE a.id = ?
135
+ AND member.internal_resource = ?
136
+ ORDER BY b.member_pos
137
+ SQL
138
+ end
139
+
140
+ # Generate the SQL query for retrieving member resources in PostgreSQL using a
141
+ # JSON object literal as an argument (e. g. { "alternate_ids": [{"id": "d6e88f80-41b3-4dbf-a2a0-cd79e20f6d10"}] }).
142
+ # @see https://guides.rubyonrails.org/active_record_querying.html#array-conditions
143
+ # This uses JSON functions in order to retrieve JSON property values
144
+ # @see https://www.postgresql.org/docs/current/static/functions-json.html
145
+ # @return [String]
146
+ def find_inverse_references_query
147
+ <<-SQL
148
+ SELECT * FROM orm_resources WHERE
149
+ metadata @> ?
150
+ SQL
151
+ end
152
+
153
+ # Generate the SQL query for retrieving member resources in PostgreSQL using a
154
+ # JSON object literal and resource ID as arguments.
155
+ # @see https://guides.rubyonrails.org/active_record_querying.html#array-conditions
156
+ # @note this uses a CROSS JOIN for all combinations of member IDs with the
157
+ # IDs of their parents
158
+ # @see https://www.postgresql.org/docs/current/static/queries-table-expressions.html#QUERIES-FROM
159
+ # This also uses JSON functions in order to retrieve JSON property values
160
+ # @see https://www.postgresql.org/docs/current/static/functions-json.html
161
+ # @return [String]
162
+ def find_references_query
163
+ <<-SQL
164
+ SELECT DISTINCT member.* FROM orm_resources a,
165
+ jsonb_array_elements(a.metadata->?) AS b(member)
166
+ JOIN orm_resources member ON (b.member->>'id')::#{id_type} = member.id WHERE a.id = ?
167
+ SQL
168
+ end
169
+
170
+ def find_ordered_references_query
171
+ <<-SQL
172
+ SELECT member.* FROM orm_resources a,
173
+ jsonb_array_elements(a.metadata->?) WITH ORDINALITY AS b(member, member_pos)
174
+ JOIN orm_resources member ON (b.member->>'id')::#{id_type} = member.id WHERE a.id = ?
175
+ ORDER BY b.member_pos
176
+ SQL
177
+ end
178
+
179
+ # Accesses the data type in PostgreSQL used for the primary key
180
+ # (For example, a UUID)
181
+ # @see https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaCache.html#method-i-columns_hash
182
+ # @return [Symbol]
183
+ def id_type
184
+ @id_type ||= :uuid
185
+ end
186
+
187
+ # Determines whether or not an Object is a Valkyrie ID
188
+ # @param [Object] id
189
+ # @raise [ArgumentError]
190
+ def validate_id(id)
191
+ raise ArgumentError, 'id must be a Valkyrie::ID' unless id.is_a? Valkyrie::ID
192
+ end
193
+
194
+ # Determines whether or not a resource has been persisted
195
+ # @param [Object] resource
196
+ # @raise [ArgumentError]
197
+ def ensure_persisted(resource)
198
+ raise ArgumentError, 'resource is not saved' unless resource.persisted?
199
+ end
200
+
201
+ def ordered_property?(resource:, property:)
202
+ resource.class.schema[property].meta.try(:[], :ordered)
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+ module Valkyrie::Sequel
3
+ class ResourceFactory::ORMConverter
4
+ attr_reader :object, :resource_factory
5
+ def initialize(object, resource_factory:)
6
+ @object = object
7
+ @resource_factory = resource_factory
8
+ end
9
+
10
+ def convert!
11
+ @resource ||= resource
12
+ end
13
+
14
+ private
15
+
16
+ # Construct a new Valkyrie Resource using the attributes retrieved from the database
17
+ # @return [Valkyrie::Resource]
18
+ def resource
19
+ resource_klass.new(
20
+ attributes.merge(
21
+ new_record: false,
22
+ Valkyrie::Persistence::Attributes::OPTIMISTIC_LOCK => lock_token
23
+ )
24
+ )
25
+ end
26
+
27
+ # Construct the optimistic lock token using the adapter and lock version for the Resource
28
+ # @return [Valkyrie::Persistence::OptimisticLockToken]
29
+ def lock_token
30
+ @lock_token ||=
31
+ Valkyrie::Persistence::OptimisticLockToken.new(
32
+ adapter_id: resource_factory.adapter_id,
33
+ token: object[:lock_version]
34
+ )
35
+ end
36
+
37
+ # Retrieve the Class used to construct the Valkyrie Resource
38
+ # @return [Class]
39
+ def resource_klass
40
+ internal_resource.constantize
41
+ end
42
+
43
+ # Access the String for the Valkyrie Resource type within the attributes
44
+ # @return [String]
45
+ def internal_resource
46
+ attributes[:internal_resource]
47
+ end
48
+
49
+ def attributes
50
+ @attributes ||= object.except(:metadata).merge(rdf_metadata).symbolize_keys
51
+ end
52
+
53
+ # Generate a Hash derived from Valkyrie Resource metadata encoded in the RDF
54
+ # @return [Hash]
55
+ def rdf_metadata
56
+ @rdf_metadata ||= Valkyrie::Persistence::Postgres::ORMConverter::RDFMetadata.new(object[:metadata]).result
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+ module Valkyrie::Sequel
3
+ class ResourceFactory::ResourceConverter
4
+ attr_reader :resource, :resource_factory
5
+ delegate :orm_class, :adapter, to: :resource_factory
6
+ delegate :resources, to: :adapter
7
+ def initialize(resource, resource_factory:)
8
+ @resource = resource
9
+ @resource_factory = resource_factory
10
+ end
11
+
12
+ def convert!
13
+ output = database_hash
14
+ output[:id] = resource.id.to_s if resource.id
15
+ output.delete(:id) unless !output[:id] || QueryService::ACCEPTABLE_UUID.match?(output[:id].to_s)
16
+ process_lock_token(output)
17
+ output
18
+ end
19
+
20
+ private
21
+
22
+ # Retrieves the optimistic lock token from the Valkyrie attribute value and
23
+ # sets it to the lock_version on ORM resource
24
+ # @see https://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
25
+ # @param [ORM::Resource] orm_object
26
+ def process_lock_token(orm_object)
27
+ return unless resource.respond_to?(Valkyrie::Persistence::Attributes::OPTIMISTIC_LOCK)
28
+ postgres_token = resource[Valkyrie::Persistence::Attributes::OPTIMISTIC_LOCK].find do |token|
29
+ token.adapter_id == resource_factory.adapter_id
30
+ end
31
+ return unless postgres_token
32
+ orm_object[:lock_version] = postgres_token.token
33
+ end
34
+
35
+ def database_hash
36
+ resource_hash.select do |k, _v|
37
+ primary_terms.include?(k)
38
+ end.compact.merge(
39
+ metadata: ::Sequel.pg_json(metadata_hash)
40
+ )
41
+ end
42
+
43
+ def resource_hash
44
+ @resource_hash ||= resource.to_h
45
+ end
46
+
47
+ # Convert attributes to all be arrays to better enable querying and
48
+ # "changing of minds" later on.
49
+ # @return [Hash]
50
+ def metadata_hash
51
+ Hash[
52
+ selected_resource_attributes.compact.map do |k, v|
53
+ [k, Array.wrap(v)]
54
+ end
55
+ ]
56
+ end
57
+
58
+ def selected_resource_attributes
59
+ resource_hash.select do |k, _v|
60
+ !primary_terms.include?(k) && !blacklist_terms.include?(k)
61
+ end
62
+ end
63
+
64
+ def primary_terms
65
+ [
66
+ :id,
67
+ :created_at,
68
+ :updated_at,
69
+ :internal_resource
70
+ ]
71
+ end
72
+
73
+ def blacklist_terms
74
+ [
75
+ :new_record
76
+ ]
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ module Valkyrie::Sequel
3
+ class ResourceFactory
4
+ require 'valkyrie/sequel/resource_factory/resource_converter'
5
+ require 'valkyrie/sequel/resource_factory/orm_converter'
6
+ attr_reader :adapter
7
+ delegate :id, to: :adapter, prefix: true
8
+ def initialize(adapter:)
9
+ @adapter = adapter
10
+ end
11
+
12
+ def to_resource(object:)
13
+ ORMConverter.new(object, resource_factory: self).convert!
14
+ end
15
+
16
+ def from_resource(resource:)
17
+ ResourceConverter.new(resource, resource_factory: self).convert!
18
+ end
19
+
20
+ def orm_class
21
+ adapter.resources
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ module Valkyrie
3
+ module Sequel
4
+ VERSION = "1.0.0"
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ require "valkyrie/sequel/version"
3
+ require 'valkyrie'
4
+ require 'sequel'
5
+ require 'sequel/adapters/postgresql'
6
+ require 'sequel_pg'
7
+
8
+ module Valkyrie
9
+ module Sequel
10
+ ::Sequel.extension(:pg_json)
11
+ ::Sequel.extension(:pg_json_ops)
12
+ ::Sequel.default_timezone = :utc
13
+ ::Sequel.extension(:oj_parser)
14
+ require 'valkyrie/sequel/metadata_adapter'
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+
2
+ # frozen_string_literal: true
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "valkyrie/sequel/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "valkyrie-sequel"
9
+ spec.version = Valkyrie::Sequel::VERSION
10
+ spec.authors = ["Trey Pendragon"]
11
+ spec.email = ["tpendragon@princeton.edu"]
12
+
13
+ spec.summary = 'Valkyrie::MetadataAdapter for Postgres using Sequel.'
14
+ spec.description = 'Valkyrie::MetadataAdapter for Postgres using Sequel.'
15
+ spec.homepage = "https://github.com/samvera-labs/valkyrie-sequel"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_dependency "sequel"
25
+ spec.add_dependency "sequel_pg"
26
+ spec.add_dependency "valkyrie", "~> 1.5.0.RC1"
27
+ spec.add_dependency "oj"
28
+ spec.add_development_dependency "bundler", "~> 1.16"
29
+ spec.add_development_dependency "rake", "~> 10.0"
30
+ spec.add_development_dependency "rspec", "~> 3.0"
31
+ spec.add_development_dependency "bixby"
32
+ spec.add_development_dependency "pry-byebug"
33
+ spec.add_development_dependency "database_cleaner"
34
+ spec.add_development_dependency "coveralls"
35
+ spec.add_development_dependency "simplecov"
36
+ end
metadata ADDED
@@ -0,0 +1,234 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: valkyrie-sequel
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Trey Pendragon
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-04-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sequel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: sequel_pg
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: valkyrie
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 1.5.0.RC1
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.5.0.RC1
55
+ - !ruby/object:Gem::Dependency
56
+ name: oj
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.16'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.16'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '10.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '10.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: bixby
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: pry-byebug
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: database_cleaner
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: coveralls
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: simplecov
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ description: Valkyrie::MetadataAdapter for Postgres using Sequel.
182
+ email:
183
+ - tpendragon@princeton.edu
184
+ executables: []
185
+ extensions: []
186
+ extra_rdoc_files: []
187
+ files:
188
+ - ".circleci/config.yml"
189
+ - ".gitignore"
190
+ - ".rspec"
191
+ - ".rubocop.yml"
192
+ - ".travis.yml"
193
+ - Gemfile
194
+ - LICENSE
195
+ - README.md
196
+ - Rakefile
197
+ - bin/console
198
+ - bin/setup
199
+ - db/migrations/.gitkeep
200
+ - db/migrations/001_create_orm_resources.rb
201
+ - lib/sequel/extensions/oj_parser.rb
202
+ - lib/valkyrie/sequel.rb
203
+ - lib/valkyrie/sequel/metadata_adapter.rb
204
+ - lib/valkyrie/sequel/persister.rb
205
+ - lib/valkyrie/sequel/query_service.rb
206
+ - lib/valkyrie/sequel/resource_factory.rb
207
+ - lib/valkyrie/sequel/resource_factory/orm_converter.rb
208
+ - lib/valkyrie/sequel/resource_factory/resource_converter.rb
209
+ - lib/valkyrie/sequel/version.rb
210
+ - valkyrie-sequel.gemspec
211
+ homepage: https://github.com/samvera-labs/valkyrie-sequel
212
+ licenses: []
213
+ metadata: {}
214
+ post_install_message:
215
+ rdoc_options: []
216
+ require_paths:
217
+ - lib
218
+ required_ruby_version: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
223
+ required_rubygems_version: !ruby/object:Gem::Requirement
224
+ requirements:
225
+ - - ">="
226
+ - !ruby/object:Gem::Version
227
+ version: '0'
228
+ requirements: []
229
+ rubyforge_project:
230
+ rubygems_version: 2.6.14
231
+ signing_key:
232
+ specification_version: 4
233
+ summary: Valkyrie::MetadataAdapter for Postgres using Sequel.
234
+ test_files: []