diff --git a/.dassie/db/migrate/20240606205215_create_hyrax_flexible_schemas.rb b/.dassie/db/migrate/20240606205215_create_hyrax_flexible_schemas.rb new file mode 100644 index 0000000000..0bc7567dec --- /dev/null +++ b/.dassie/db/migrate/20240606205215_create_hyrax_flexible_schemas.rb @@ -0,0 +1,10 @@ +class CreateHyraxFlexibleSchemas < ActiveRecord::Migration[6.1] + def change + create_table :hyrax_flexible_schemas do |t| + t.string :version, index: { unique: true } + t.text :profile + + t.timestamps + end + end +end diff --git a/.dassie/db/schema.rb b/.dassie/db/schema.rb index 1515b687c5..d58a685ed5 100644 --- a/.dassie/db/schema.rb +++ b/.dassie/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_05_06_070809) do +ActiveRecord::Schema.define(version: 2024_06_06_205215) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -172,6 +172,13 @@ t.datetime "updated_at", null: false end + create_table "hyrax_flexible_schemas", force: :cascade do |t| + t.string "version" + t.text "profile" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + create_table "job_io_wrappers", force: :cascade do |t| t.bigint "user_id" t.bigint "uploaded_file_id" diff --git a/app/models/concerns/hyrax/flexibility.rb b/app/models/concerns/hyrax/flexibility.rb index a293ad7066..b20c03ac79 100644 --- a/app/models/concerns/hyrax/flexibility.rb +++ b/app/models/concerns/hyrax/flexibility.rb @@ -3,6 +3,10 @@ module Hyrax module Flexibility extend ActiveSupport::Concern + included do + attribute :schema_version, Valkyrie::Types::String + end + class_methods do ## Override dry-struct 1.6.0 to enable redefining schemas on the fly def attributes(new_schema) @@ -55,10 +59,10 @@ def new(attributes = default_attributes, safe = false, &block) # rubocop:disable ## Read the schema from the database and load the correct schemas for the instance in to the class def load(attributes, safe = false) - attributes[:schemas] ||= 'core_metadata:1' + attributes[:schema_version] ||= Hyrax::FlexibleSchema.order('created_at DESC').pick(:version) struct = allocate - schema_name, schema_version = attributes[:schemas].split(':') - struct.singleton_class.attributes(Hyrax::Schema(schema_name, schema_version:).attributes) + schema_version = attributes[:schema_version] + struct.singleton_class.attributes(Hyrax::Schema(self, schema_version:).attributes) clean_attributes = safe ? struct.singleton_class.schema.call_safe(attributes) { |output = attributes| return yield output } : struct.singleton_class.schema.call_unsafe(attributes) struct.__send__(:initialize, clean_attributes) struct diff --git a/app/models/hyrax/flexible_schema.rb b/app/models/hyrax/flexible_schema.rb new file mode 100644 index 0000000000..428978d7b1 --- /dev/null +++ b/app/models/hyrax/flexible_schema.rb @@ -0,0 +1,33 @@ +class Hyrax::FlexibleSchema < ApplicationRecord + serialize :profile, coder: YAML + + def attributes_for(class_name) + class_names[class_name] + end + + def class_names + return @class_names if @class_names + @class_names = {} + profile['classes'].keys.each do |class_name| + @class_names[class_name] = {} + end + profile['properties'].each do |key, value| + value['available_on']['class'].each do |property_class| + # map some m3 items to what Hyrax expects + value['type'] = lookup_type(value['range']) + value['predicate'] = value['property_uri'] + @class_names[property_class][key] = value + end + end + @class_names + end + + def lookup_type(range) + case range + when "http://www.w3.org/2001/XMLSchema#dateTime" + 'date_time' + else + range.split('#').last.underscore + end + end +end diff --git a/app/models/hyrax/resource.rb b/app/models/hyrax/resource.rb index df24eae83c..8ae43e372a 100644 --- a/app/models/hyrax/resource.rb +++ b/app/models/hyrax/resource.rb @@ -38,7 +38,6 @@ class Resource < Valkyrie::Resource attribute :alternate_ids, Valkyrie::Types::Array.of(Valkyrie::Types::ID) attribute :embargo_id, Valkyrie::Types::Params::ID attribute :lease_id, Valkyrie::Types::Params::ID - attribute :schemas, Valkyrie::Types::String delegate :edit_groups, :edit_groups=, :edit_users, :edit_users=, diff --git a/app/services/hyrax/m3_schema_loader.rb b/app/services/hyrax/m3_schema_loader.rb new file mode 100644 index 0000000000..094723fcbe --- /dev/null +++ b/app/services/hyrax/m3_schema_loader.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module Hyrax + ## + # @api private + # + # Read m3 profiles from the database + # + # @see config/metadata/m3_profile.yaml for an example configuration + class M3SchemaLoader + ## + # @param [Symbol] schema + # + # @return [Hash] a map from attribute names to + # types + def attributes_for(schema:, version: 1) + definitions(schema, version).each_with_object({}) do |definition, hash| + hash[definition.name] = definition.type.meta(definition.config) + end + end + + ## + # @param [Symbol] schema + # + # @return [Hash{Symbol => Hash{Symbol => Object}}] + def form_definitions_for(schema:, version: 1) + definitions(schema, version).each_with_object({}) do |definition, hash| + next if definition.form_options.empty? + + hash[definition.name] = definition.form_options + end + end + + ## + # @param [Symbol] schema + # + # @return [{Symbol => Symbol}] a map from index keys to attribute names + def index_rules_for(schema:, version: 1) + definitions(schema, version).each_with_object({}) do |definition, hash| + definition.index_keys.each do |key| + hash[key] = definition.name + end + end + end + + ## + # @api private + class AttributeDefinition + ## + # @!attr_reader :config + # @return [Hash] + # @!attr_reader :name + # @return [#to_sym] + attr_reader :config, :name + + ## + # @param [#to_sym] name + # @param [Hash] config + def initialize(name, config) + @config = config + @name = name.to_sym + end + + ## + # @return [Hash{Symbol => Object}] + def form_options + config.fetch('form', {}).symbolize_keys + end + + ## + # @return [Enumerable] + def index_keys + config.fetch('indexing', []).map(&:to_sym) + end + + ## + # @return [Dry::Types::Type] + def type + collection_type = if config['multi_value'] + Valkyrie::Types::Array.constructor { |v| Array(v).select(&:present?) } + else + Identity + end + collection_type.of(type_for(config['type'])) + end + + ## + # @api private + # + # This class acts as a Valkyrie/Dry::Types collection with typed members, + # but instead of wrapping the given type with itself as the collection type + # (as in `Valkyrie::Types::Array.of(MyType)`), it returns the given type. + # + # @example + # Identity.of(Valkyrie::Types::String) # => Valkyrie::Types::String + # + class Identity + ## + # @param [Dry::Types::Type] + # @return [Dry::Types::Type] the type passed in + def self.of(type) + type + end + end + + private + + ## + # Maps a configuration string value to a `Valkyrie::Type`. + # + # @param [String] + # @return [Dry::Types::Type] + def type_for(type) + case type + when 'id' + Valkyrie::Types::ID + when 'uri' + Valkyrie::Types::URI + when 'date_time' + Valkyrie::Types::DateTime + else + "Valkyrie::Types::#{type.capitalize}".constantize + end + end + end + + class UndefinedSchemaError < ArgumentError; end + + private + + ## + # @param [#to_s] schema_name + # @return [Enumerable] a map from attribute names to # types - def attributes_for(schema:) + def attributes_for(schema:, version: 1) definitions(schema).each_with_object({}) do |definition, hash| hash[definition.name] = definition.type.meta(definition.config) end @@ -23,7 +23,7 @@ def attributes_for(schema:) # @param [Symbol] schema # # @return [Hash{Symbol => Hash{Symbol => Object}}] - def form_definitions_for(schema:) + def form_definitions_for(schema:, version: 1) definitions(schema).each_with_object({}) do |definition, hash| next if definition.form_options.empty? @@ -35,7 +35,7 @@ def form_definitions_for(schema:) # @param [Symbol] schema # # @return [{Symbol => Symbol}] a map from index keys to attribute names - def index_rules_for(schema:) + def index_rules_for(schema:, version: 1) definitions(schema).each_with_object({}) do |definition, hash| definition.index_keys.each do |key| hash[key] = definition.name diff --git a/config/metadata/m3_profile.yaml b/config/metadata/m3_profile.yaml new file mode 100644 index 0000000000..49d2a05d75 --- /dev/null +++ b/config/metadata/m3_profile.yaml @@ -0,0 +1,148 @@ +--- +m3_version: 1.0.beta2 +profile: + date_modified: '2024-06-01' + responsibility: https://samvera.org + responsibility_statement: Hyrax Initial Profile + type: + version: 1 +classes: + Hyrax::Work: + display_label: Work + Hyrax::AdministrativeSet: + diplay_label: AdministrativeSet + Hyrax::PcdmCollection: + display_label: PcdmCollection + Hyrax::FileSet: + display_label: FileSet +contexts: + flexible_context: + display_label: Flexible Metadata Example +mappings: + blacklight: + name: Additional Blacklight Solr Mappings + metatags: + name: Metatags + mods_oai_pmh: + name: MODS OAI PMH + qualified_dc_pmh: + name: Qualified DC OAI PMH + simple_dc_pmh: + name: Simple DC OAI PMH +properties: + title: + available_on: + class: + - Hyrax::AdministrativeSet + - Hyrax::FileSet + - Hyrax::PcdmCollection + - Hyrax::Work + cardinality: + minimum: 0 + multi_value: true + controlled_values: + format: http://www.w3.org/2001/XMLSchema#string + sources: + - 'null' + definition: + default: Enter a standardized title for display. If only one + title is needed, transcribe the title from the source + itself. + display_label: + default: Title + index_documentation: displayable, searchable + indexing: + - 'title_sim' + - 'title_tesim' + form: + required: true + primary: true + multi_value: true + mappings: + metatags: twitter:title, og:title + mods_oai_pmh: mods:titleInfo/mods:title + qualified_dc_pmh: dcterms:title + simple_dc_pmh: dc:title + property_uri: http://purl.org/dc/terms/title + range: http://www.w3.org/2001/XMLSchema#string + requirement: required + sample_values: + - Pencil drawn portrait study of woman + date_modified: + available_on: + class: + - Hyrax::AdministrativeSet + - Hyrax::FileSet + - Hyrax::PcdmCollection + - Hyrax::Work + cardinality: + minimum: 0 + maximum: 1 + multi_value: false + display_label: + default: Date Modified + property_uri: http://purl.org/dc/terms/modified + range: http://www.w3.org/2001/XMLSchema#dateTime + sample_values: + - "2024-06-06 21:06:51 +0000" + date_uploaded: + available_on: + class: + - Hyrax::AdministrativeSet + - Hyrax::FileSet + - Hyrax::PcdmCollection + - Hyrax::Work + cardinality: + minimum: 0 + maximum: 1 + multi_value: false + display_label: + default: Date Uploaded + property_uri: http://purl.org/dc/terms/dateSubmitted + range: http://www.w3.org/2001/XMLSchema#dateTime + sample_values: + - "2024-06-06 21:06:51 +0000" + depositor: + available_on: + class: + - Hyrax::AdministrativeSet + - Hyrax::FileSet + - Hyrax::PcdmCollection + - Hyrax::Work + cardinality: + minimum: 0 + maximum: 1 + multi_value: false + controlled_values: + format: http://www.w3.org/2001/XMLSchema#string + sources: + - 'null' + display_label: + default: Depositor + index_documentation: searchable + indexing: + - 'depositor_tesim' + property_uri: http://id.loc.gov/vocabulary/relators/dpt + range: http://www.w3.org/2001/XMLSchema#string + sample_values: + - Julie Allinson + creator: + available_on: + class: + - Hyrax::FileSet + cardinality: + minimum: 1 + multi_value: true + controlled_values: + format: http://www.w3.org/2001/XMLSchema#string + sources: + - 'null' + display_label: + default: Creator + index_documentation: searchable + indexing: + - 'creator_tesim' + property_uri: http://purl.org/dc/elements/1.1/creator + range: http://www.w3.org/2001/XMLSchema#string + sample_values: + - Julie Allinson diff --git a/lib/generators/hyrax/templates/db/migrate/20240606205215_create_hyrax_flexible_schemas.rb.erb b/lib/generators/hyrax/templates/db/migrate/20240606205215_create_hyrax_flexible_schemas.rb.erb new file mode 100644 index 0000000000..4cba9be7cc --- /dev/null +++ b/lib/generators/hyrax/templates/db/migrate/20240606205215_create_hyrax_flexible_schemas.rb.erb @@ -0,0 +1,10 @@ +class CreateHyraxFlexibleSchemas < ActiveRecord::Migration<%= migration_version %> + def change + create_table :hyrax_flexible_schemas do |t| + t.string :version, index: { unique: true } + t.text :profile + + t.timestamps + end + end +end diff --git a/lib/hyrax/schema.rb b/lib/hyrax/schema.rb index 4965545bfb..90929a9340 100644 --- a/lib/hyrax/schema.rb +++ b/lib/hyrax/schema.rb @@ -56,12 +56,12 @@ class Schema < Module ## # @!attribute [r] name # @return [Symbol] - attr_reader :name + attr_reader :name, :version ## # Pick the default schema loader based on whether flex is on or not def self.default_schema_loader - ENV.fetch('HYRAX_FLEXIBLE', false) ? SimpleSchemaLoader.new : SimpleSchemaLoader.new + ENV.fetch('HYRAX_FLEXIBLE', false) ? M3SchemaLoader.new : SimpleSchemaLoader.new end ## # @param [Symbol] schema_name @@ -70,7 +70,7 @@ def self.default_schema_loader # # @api private def initialize(schema_name, schema_loader: Hyrax::Schema.default_schema_loader, schema_version: '1') - @name = schema_name + @name = schema_name.to_s @version = schema_version @schema_loader = schema_loader end @@ -78,7 +78,7 @@ def initialize(schema_name, schema_loader: Hyrax::Schema.default_schema_loader, ## # @return [Hash{Symbol => Dry::Types::Type}] def attributes - @schema_loader.attributes_for(schema: name) + @schema_loader.attributes_for(schema: name, version:) end ## diff --git a/spec/models/hyrax/flexible_schema_spec.rb b/spec/models/hyrax/flexible_schema_spec.rb new file mode 100644 index 0000000000..45531ade88 --- /dev/null +++ b/spec/models/hyrax/flexible_schema_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Hyrax::FlexibleSchema, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/services/hyrax/m3_schema_loader_spec.rb b/spec/services/hyrax/m3_schema_loader_spec.rb new file mode 100644 index 0000000000..9c8b900dd3 --- /dev/null +++ b/spec/services/hyrax/m3_schema_loader_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Hyrax::M3SchemaLoader do + subject(:schema_loader) { described_class.new } + + describe '#attributes_for' do + it 'provides an attributes hash' do + expect(schema_loader.attributes_for(schema: :core_metadata)) + .to include(title: Valkyrie::Types::Array.of(Valkyrie::Types::String), + depositor: Valkyrie::Types::String) + end + + it 'provides access to attribute metadata' do + expect(schema_loader.attributes_for(schema: :core_metadata)[:title].meta) + .to include({ "type" => "string", + "form" => { "multiple" => true, "primary" => true, "required" => true }, + "index_keys" => ["title_sim", "title_tesim"], + "multiple" => true, + "predicate" => "http://purl.org/dc/terms/title" }) + end + + context 'with generated resource' do + it 'provides an attributes hash' do + expect(schema_loader.attributes_for(schema: :sample_metadata)) + .to include(sample_attribute: Valkyrie::Types::Array.of(Valkyrie::Types::String)) + end + end + + it 'raises an error for an undefined schema' do + expect { schema_loader.attributes_for(schema: :NOT_A_SCHEMA) } + .to raise_error described_class::UndefinedSchemaError + end + end + + describe '#index_rules_for' do + it 'provides index configuration' do + expect(schema_loader.index_rules_for(schema: :core_metadata)).to include(title_sim: :title, title_tesim: :title) + end + end + + describe '#form_definitions_for' do + it 'provides form configuration' do + expect(schema_loader.form_definitions_for(schema: :core_metadata)) + .to eq(title: { required: true, primary: true, multiple: true }) + end + end + + describe '#permissive_schema_for_valkrie_adapter' do + let(:permissive_schema) { schema_loader.permissive_schema_for_valkrie_adapter } + + it 'provides the expected hash' do + expect(permissive_schema.size).to eq(66) + expect(permissive_schema.values.all? { |v| v.is_a? RDF::URI }).to be_truthy + expect(permissive_schema.values.all? { |v| v.value.present? }).to be_truthy + expect(permissive_schema[:sample_attribute].value).to eq("http://hyrax-example.com/sample_attribute") + end + end +end