diff --git a/.rubocop.yml b/.rubocop.yml index 288c681b..1c32fb8b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -406,6 +406,10 @@ Rails/AddColumnIndex: Rails/AfterCommitOverride: Enabled: true +Rails/ApplicationRecord: + Exclude: + - "lib/**/*.rb" + Rails/AttributeDefaultBlockValue: Enabled: true @@ -646,6 +650,7 @@ RSpec/DuplicatedMetadata: RSpec/EmptyExampleGroup: Exclude: - spec/policies/**/*.rb + - spec/services/filtering/scopes/**/*.rb - spec/support/shared_examples/**/*.rb RSpec/EmptyLineAfterFinalLet: diff --git a/Gemfile b/Gemfile index e49b3f27..78e3d78a 100644 --- a/Gemfile +++ b/Gemfile @@ -91,7 +91,7 @@ gem "faraday-retry", "~> 2.4" gem "ffi", "~> 1.17" gem "fugit", "~> 1.12" gem "geocoder", "~> 1.8" -gem "groupdate", "~> 6.7" +gem "groupdate", "~> 6.8" gem "hashids", "~> 1.0" gem "htmlbeautifier", "~> 1.4" gem "iso-639", "~> 0.3" diff --git a/Gemfile.lock b/Gemfile.lock index d5646406..e93cebcd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,7 +129,7 @@ GEM attr_required (1.0.2) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1241.0) + aws-partitions (1.1246.0) aws-sdk-core (3.246.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -141,7 +141,7 @@ GEM aws-sdk-kms (1.124.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.220.0) + aws-sdk-s3 (1.221.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -156,7 +156,7 @@ GEM racc (~> 1.7) bigdecimal (4.1.2) bindata (2.5.1) - bootsnap (1.24.0) + bootsnap (1.24.3) msgpack (~> 1.2) builder (3.3.0) cgi (0.5.1) @@ -289,7 +289,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - factory_bot (6.5.6) + factory_bot (6.6.0) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) @@ -377,8 +377,8 @@ GEM graphql (>= 1.13.0) graphql-fragment_cache (1.22.2) graphql (>= 2.1.4) - groupdate (6.7.0) - activesupport (>= 7.1) + groupdate (6.8.0) + activesupport (>= 7.2) hana (1.3.7) hashdiff (1.2.1) hashids (1.0.6) @@ -410,9 +410,9 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - job-iteration (1.13.0) + job-iteration (1.13.1) activejob (>= 7.0) - json (2.19.4) + json (2.19.5) json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap @@ -488,7 +488,7 @@ GEM logger mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.5) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) mods (3.0.5) @@ -497,9 +497,9 @@ GEM iso-639 nokogiri (>= 1.6.6) nom-xml (~> 1.0) - moxml (0.1.18) + moxml (0.1.20) msgpack (1.8.0) - multi_json (1.20.1) + multi_json (1.21.1) mustermann (3.1.1) mutex_m (0.3.0) namae (1.2.0) @@ -672,7 +672,7 @@ GEM redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.28.0) + redis-client (0.29.0) connection_pool redis-objects (2.0.0) redis (~> 5.0) @@ -841,7 +841,7 @@ GEM validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix - vernier (1.10.0) + vernier (1.10.1) wapiti (2.1.0) builder (~> 3.2) rexml (~> 3.0) @@ -941,7 +941,7 @@ DEPENDENCIES graphql-batch (~> 0.6) graphql-client (~> 0.26) graphql-fragment_cache (~> 1.22) - groupdate (~> 6.7) + groupdate (~> 6.8) hashids (~> 1.0) htmlbeautifier (~> 1.4) image_processing (~> 1.14) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e4419eb2..8cd123a0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,6 +7,7 @@ class ApplicationController < ActionController::API include OperationHelpers before_action :attach_request_id! + before_action :load_current_global_configuration! # @return [void] def attach_request_id! @@ -37,6 +38,11 @@ def authenticate_user! end end + # @return [void] + def load_current_global_configuration! + GlobalConfiguration.current! + end + # Render a standard, localized server error using {#render_single_error!}. # # @api private diff --git a/app/graphql/mutations/contributor_claim.rb b/app/graphql/mutations/contributor_claim.rb new file mode 100644 index 00000000..ff7a2592 --- /dev/null +++ b/app/graphql/mutations/contributor_claim.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Mutations + # @see Mutations::Operations::ContributorClaim + class ContributorClaim < Mutations::BaseMutation + description <<~TEXT + A mutation to claim a contributor profile as the current user. + + This is intended to be used by depositors who have already had contributions harvested + and may have an existing `Contributor` record in the system. + + It relies upon the `canClaim` permission on the given `Contributor`, + and by proxy, whether or not `Contributor.claimed` is `false`. + TEXT + + field :contributor, Types::ContributorType, null: true do + description <<~TEXT + The contributor that was claimed by the current user, if successful. + TEXT + end + + field :contributor_user_link, Types::ContributorUserLinkType, null: true do + description <<~TEXT + The link between the claimed contributor and the current user, if the claim was successful. + TEXT + end + + field :user, Types::UserType, null: true do + description <<~TEXT + The current user, if the claim was successful. + TEXT + end + + argument :contributor_id, ID, loads: Types::ContributorType, required: true do + description <<~TEXT + The ID of the contributor to claim. This should be a contributor that has already been harvested for the current user. + TEXT + end + + performs_operation! "mutations.operations.contributor_claim" + end +end diff --git a/app/graphql/mutations/contributor_merge.rb b/app/graphql/mutations/contributor_merge.rb new file mode 100644 index 00000000..ba697f67 --- /dev/null +++ b/app/graphql/mutations/contributor_merge.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + # @see Mutations::Operations::ContributorMerge + class ContributorMerge < Mutations::BaseMutation + description <<~TEXT + Merge two contributors. + + The actual merging will occur in the background after a delay, but the source + contributor will be marked as `MERGING` immediately. + TEXT + + field :source, Types::ContributorType, null: true do + description <<~TEXT + The contributor being merged, if successful. + TEXT + end + + field :target, Types::ContributorType, null: true do + description <<~TEXT + The contributor being merged into, if successful. + TEXT + end + + argument :source_id, ID, loads: Types::ContributorType, required: true do + description <<~TEXT + The ID of the contributor to merge. + TEXT + end + + argument :target_id, ID, loads: Types::ContributorType, required: true do + description <<~TEXT + The ID of the contributor to merge into. + TEXT + end + + performs_operation! "mutations.operations.contributor_merge" + end +end diff --git a/app/graphql/mutations/contributor_user_link_upsert.rb b/app/graphql/mutations/contributor_user_link_upsert.rb index 630e96fc..8289d3dd 100644 --- a/app/graphql/mutations/contributor_user_link_upsert.rb +++ b/app/graphql/mutations/contributor_user_link_upsert.rb @@ -5,6 +5,8 @@ module Mutations class ContributorUserLinkUpsert < Mutations::BaseMutation description <<~TEXT Create or update a link between a `Contributor` and a `User`. + + It relies upon the `canLinkUser` permission on the given `Contributor`. TEXT field :contributor, Types::ContributorType, null: true do diff --git a/app/graphql/mutations/submission_target_configure.rb b/app/graphql/mutations/submission_target_configure.rb index 46541798..f8791e19 100644 --- a/app/graphql/mutations/submission_target_configure.rb +++ b/app/graphql/mutations/submission_target_configure.rb @@ -62,9 +62,11 @@ class SubmissionTargetConfigure < Mutations::BaseMutation TEXT end - argument :auto_approve_depositors, Boolean, required: false, default_value: false, replace_null_with_default: true do + argument :auto_approve_depositors, Boolean, required: false, default_value: MeruConfig.auto_approve_depositors, replace_null_with_default: true do description <<~TEXT Whether depositors should be automatically approved when they request to become a depositor for this submission target. + + The default value for this field can be set on a tenant level, but is `true` by default for now. TEXT end diff --git a/app/graphql/mutations/update_global_configuration.rb b/app/graphql/mutations/update_global_configuration.rb index 0de4ec7d..87aa7646 100644 --- a/app/graphql/mutations/update_global_configuration.rb +++ b/app/graphql/mutations/update_global_configuration.rb @@ -19,6 +19,10 @@ class UpdateGlobalConfiguration < Mutations::BaseMutation TEXT end + argument :contributors, Types::Settings::ContributorsSettingsInputType, required: false do + description "Possible new settings for contributors" + end + argument :depositing, Types::Settings::DepositingSettingsInputType, required: false do description "Possible new settings for depositing behavior" end diff --git a/app/graphql/types/abstract_model.rb b/app/graphql/types/abstract_model.rb deleted file mode 100644 index 9dd8fd41..00000000 --- a/app/graphql/types/abstract_model.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Types - # @abstract - class AbstractModel < Types::BaseObject - implements GraphQL::Types::Relay::Node - - implements ::Types::CommonPermissionsType - implements ::Types::HasDefaultTimestampsType - implements ::Types::SluggableType - - global_id_field :id - - class << self - # @param [ApplicationRecord] object - # @param [GraphQL::Query::Context] graphql_context - # @raise [ActionPolicy::NotFound] if a policy cannot be found for the object - def authorized?(object, graphql_context) - context = { user: graphql_context[:current_user], } - - if graphql_context[:current_object].kind_of?(::Types::MutationType) - # This is an object being loaded as an argument in a mutation. - # If we can't even read it, throw an exception that GQL will catch and skip the mutation entirely. - return authorize!(object, to: :read_for_mutation?, context:) - end - - allowed_to?(:show?, object, context:) - end - - def inherited(subclass) - super if defined?(super) - - subclass.global_id_field :id - end - end - end -end diff --git a/app/graphql/types/admin_permission_grid_type.rb b/app/graphql/types/admin_permission_grid_type.rb new file mode 100644 index 00000000..2baa124b --- /dev/null +++ b/app/graphql/types/admin_permission_grid_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + # @see Roles::AdminPermissionGrid + class AdminPermissionGridType < Types::BaseObject + description <<~TEXT + Permissions tied to the admin section of Meru. + TEXT + + implements Types::PermissionGridType + + field :access, Boolean, null: false do + description <<~TEXT + A permission to access the admin section of Meru. + + This is checked in order to determine whether or not + the client should redirect from the admin dashboard (or any admin section) + when a user tries to access it. + + Actual access to specific admin features is determined by other permissions. + TEXT + end + end +end diff --git a/app/graphql/types/announcement_type.rb b/app/graphql/types/announcement_type.rb index 4ab61355..754be590 100644 --- a/app/graphql/types/announcement_type.rb +++ b/app/graphql/types/announcement_type.rb @@ -5,7 +5,7 @@ module Types # @see Mutations::CreateAnnouncement # @see Mutations::DestroyAnnouncement # @see Mutations::UpdateAnnouncement - class AnnouncementType < Types::AbstractModel + class AnnouncementType < Types::BaseModel description <<~TEXT An announcement tied to an entity. These are configured through the backend and can be used to provide time-sensensitive information and news about a specific entity in the system. diff --git a/app/graphql/types/asset_audio_type.rb b/app/graphql/types/asset_audio_type.rb index c9391668..38631eeb 100644 --- a/app/graphql/types/asset_audio_type.rb +++ b/app/graphql/types/asset_audio_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class AssetAudioType < Types::AbstractModel + class AssetAudioType < Types::BaseModel implements Types::AssetType end end diff --git a/app/graphql/types/asset_document_type.rb b/app/graphql/types/asset_document_type.rb index a0601c46..3da6c765 100644 --- a/app/graphql/types/asset_document_type.rb +++ b/app/graphql/types/asset_document_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class AssetDocumentType < Types::AbstractModel + class AssetDocumentType < Types::BaseModel implements Types::AssetType end end diff --git a/app/graphql/types/asset_image_type.rb b/app/graphql/types/asset_image_type.rb index 5680799d..cf6f99cc 100644 --- a/app/graphql/types/asset_image_type.rb +++ b/app/graphql/types/asset_image_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class AssetImageType < Types::AbstractModel + class AssetImageType < Types::BaseModel implements Types::AssetType end end diff --git a/app/graphql/types/asset_pdf_type.rb b/app/graphql/types/asset_pdf_type.rb index 9520b6fa..f4e511f3 100644 --- a/app/graphql/types/asset_pdf_type.rb +++ b/app/graphql/types/asset_pdf_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class AssetPDFType < Types::AbstractModel + class AssetPDFType < Types::BaseModel implements Types::AssetType end end diff --git a/app/graphql/types/asset_type.rb b/app/graphql/types/asset_type.rb index e5f19bde..815c4e6c 100644 --- a/app/graphql/types/asset_type.rb +++ b/app/graphql/types/asset_type.rb @@ -5,7 +5,7 @@ module AssetType include Types::BaseInterface implements ::GraphQL::Types::Relay::Node - implements ::Types::SluggableType + implements ::Support::GQL::SluggableType description "A generic asset type, implemented by all the more specific kinds" diff --git a/app/graphql/types/asset_unknown_type.rb b/app/graphql/types/asset_unknown_type.rb index 2c8f0901..59450aff 100644 --- a/app/graphql/types/asset_unknown_type.rb +++ b/app/graphql/types/asset_unknown_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class AssetUnknownType < Types::AbstractModel + class AssetUnknownType < Types::BaseModel implements Types::AssetType end end diff --git a/app/graphql/types/asset_video_type.rb b/app/graphql/types/asset_video_type.rb index 904e44b4..eba9f0ac 100644 --- a/app/graphql/types/asset_video_type.rb +++ b/app/graphql/types/asset_video_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class AssetVideoType < Types::AbstractModel + class AssetVideoType < Types::BaseModel implements Types::AssetType end end diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb index ace72182..9f765107 100644 --- a/app/graphql/types/base_argument.rb +++ b/app/graphql/types/base_argument.rb @@ -2,80 +2,6 @@ module Types # @abstract - class BaseArgument < GraphQL::Schema::Argument - # @abstract - def initialize(*args, public_values: [], attribute: true, transient: false, replace_null_with_default: nil, **kwargs, &block) - @attribute = attribute - @transient = transient - @public_values = Array(public_values).flatten - - replace_null_with_default = !kwargs[:default_value].nil? if replace_null_with_default.nil? - - super(*args, replace_null_with_default:, **kwargs, &block) - end - - def attribute? - @attribute.present? - end - - # @return [] - def attribute_names(names: [], parent: nil) - argument_paths_for_if(&:attribute?) - end - - def authorized?(obj, arg_value, ctx) - if should_check_public_values?(ctx) - super && arg_value.in?(@public_values) - else - super - end - end - - def has_public_values? - @public_values.present? - end - - # @param [] names - # @param [String, nil] parent - # @yield [arg] - # @yieldparam [Types::BaseArgument] arg - # @yieldreturn [Boolean] - # @return [] - def argument_paths_for_if(names: [], parent: nil, &block) - argument_name = [parent, keyword || name].compact.join(?.) - - names << argument_name if yield(self) - - nested_arguments.each_with_object(names) do |arg, n| - names += arg.argument_paths_for_if(names: n, parent: argument_name, &block) if yield(arg) - end - end - - # @api private - # @return [] - def nested_arguments - if type.respond_to?(:arguments) - type.arguments.values - elsif type.respond_to?(:of_type) && type.of_type.respond_to?(:arguments) - type.of_type.arguments.values - else - [] - end - end - - def should_check_public_values?(ctx) - has_public_values? && !ctx[:current_user]&.has_admin_access? - end - - # @return [] - def transient_arguments(names: [], parent: nil) - argument_paths_for_if(&:transient?).map do |arg| - arg.split(?.).map { _1.to_s.underscore }.join(?.).to_sym - end - end - - def transient? - @transient - end + class BaseArgument < ::Support::GQL::BaseArgument end end diff --git a/app/graphql/types/base_connection.rb b/app/graphql/types/base_connection.rb index 8aec841f..bbbfb1b1 100644 --- a/app/graphql/types/base_connection.rb +++ b/app/graphql/types/base_connection.rb @@ -1,17 +1,7 @@ # frozen_string_literal: true module Types - class BaseConnection < Types::AbstractObjectType - # add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides - include GraphQL::Types::Relay::ConnectionBehaviors - - implements Types::PaginatedType - - # Override built-in pageInfo field type with our type, supporting page-based pagination - get_field("pageInfo").type = GraphQL::Schema::Member::BuildType.parse_type(Types::PageInfoType, null: false) - - edge_nullable false - node_nullable false - edges_nullable false + # @abstract + class BaseConnection < ::Support::GQL::BaseConnection end end diff --git a/app/graphql/types/base_edge.rb b/app/graphql/types/base_edge.rb index 1b1fd689..6500b2c7 100644 --- a/app/graphql/types/base_edge.rb +++ b/app/graphql/types/base_edge.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true module Types - class BaseEdge < Types::AbstractObjectType - # add `node` and `cursor` fields, as well as `node_type(...)` override - include GraphQL::Types::Relay::EdgeBehaviors - - node_nullable false + # @abstract + class BaseEdge < ::Support::GQL::BaseEdge end end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index d5aa7c7d..ef0b7f50 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -2,7 +2,6 @@ module Types # @abstract - class BaseEnum < ::GraphQL::Schema::Enum - include Support::GraphQLAPI::Enhancements::Enum + class BaseEnum < ::Support::GQL::BaseEnum end end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 8b3cf13d..7b054599 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true module Types - class BaseField < GraphQL::Schema::Field - prepend ActionPolicy::GraphQL::AuthorizedField - - argument_class Types::BaseArgument + # @abstract + class BaseField < ::Support::GQL::BaseField + argument_class ::Types::BaseArgument end end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb index 4eeb5759..6dd2800a 100644 --- a/app/graphql/types/base_input_object.rb +++ b/app/graphql/types/base_input_object.rb @@ -2,7 +2,7 @@ module Types # @abstract - class BaseInputObject < GraphQL::Schema::InputObject - argument_class Types::BaseArgument + class BaseInputObject < ::Support::GQL::BaseInputObject + argument_class ::Types::BaseArgument end end diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb index 3c3aaba0..3a07be9a 100644 --- a/app/graphql/types/base_interface.rb +++ b/app/graphql/types/base_interface.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true module Types + # @abstract module BaseInterface extend Support::GraphQLAPI::Enhancements::Interface - edge_type_class Types::BaseEdge - connection_type_class Types::BaseConnection + edge_type_class ::Types::BaseEdge + connection_type_class ::Types::BaseConnection - field_class Types::BaseField + field_class ::Types::BaseField end end diff --git a/app/graphql/types/base_model.rb b/app/graphql/types/base_model.rb new file mode 100644 index 00000000..6fa14b8b --- /dev/null +++ b/app/graphql/types/base_model.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + # @abstract The base model type for all VOG GraphQL object types that represent application models. + class BaseModel < ::Types::BaseObject + include ::Support::GraphQLAPI::BaseModelInterface + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb index ea3e2108..31292f4b 100644 --- a/app/graphql/types/base_object.rb +++ b/app/graphql/types/base_object.rb @@ -1,20 +1,13 @@ # frozen_string_literal: true module Types - class BaseObject < Types::AbstractObjectType - edge_type_class Types::BaseEdge + # @abstract + class BaseObject < ::Support::GQL::BaseObject + edge_type_class ::Types::BaseEdge - connection_type_class Types::BaseConnection + connection_type_class ::Types::BaseConnection - field_class Types::BaseField - - def call_operation(name, ...) - MeruAPI::Container[name].call(...) - end - - def call_operation!(name, ...) - call_operation(name, ...).value! - end + field_class ::Types::BaseField # @api private # @param [HierarchicalEntity, nil] promise diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb index ef0e5ef9..9cb1521f 100644 --- a/app/graphql/types/base_scalar.rb +++ b/app/graphql/types/base_scalar.rb @@ -1,47 +1,7 @@ # frozen_string_literal: true module Types - class BaseScalar < GraphQL::Schema::Scalar - extend Dry::Core::ClassAttributes - - # @api private - module DryScalar - extend ActiveSupport::Concern - - included do - defines :dry_type, type: ::Support::Types::DryType - end - - module ClassMethods - # @param [Object] input_value - # @raise [GraphQL::ExecutionError] If an invalid input value is provided, return a top-level GQL execution error. - # @return [Object] - def coerce_input(input_value, _) - dry_type[input_value] - rescue Dry::Types::ConstraintError => e - raise GraphQL::ExecutionError, "Invalid Scalar Input: #{e.message}" - end - - # @param [Object] ruby_value - # @raise [Dry::Types::ConstraintError] we should raise this at runtime - # because we should never be sending invalid values through the API. - # @return [Object] - def coerce_result(ruby_value, _) - dry_type[ruby_value] - end - end - end - - class << self - # Set up a scalar to wrap around a dry-type for validation. - # - # @param [Dry::Types::Type] type - # @return [void] - def wraps_dry_type!(type) - include DryScalar - - dry_type type - end - end + # @abstract + class BaseScalar < ::Support::GQL::BaseScalar end end diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb index 95941696..9a809cff 100644 --- a/app/graphql/types/base_union.rb +++ b/app/graphql/types/base_union.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true module Types - class BaseUnion < GraphQL::Schema::Union - edge_type_class(Types::BaseEdge) - connection_type_class(Types::BaseConnection) + # @abstract + class BaseUnion < ::Support::GQL::BaseUnion + edge_type_class ::Types::BaseEdge + + connection_type_class ::Types::BaseConnection end end diff --git a/app/graphql/types/child_entity_type.rb b/app/graphql/types/child_entity_type.rb index 9d45d978..6a53e28e 100644 --- a/app/graphql/types/child_entity_type.rb +++ b/app/graphql/types/child_entity_type.rb @@ -5,13 +5,14 @@ module Types module ChildEntityType include Types::BaseInterface + implements Support::GQL::HasDefaultTimestampsType + implements Types::EntityType implements Types::AccessibleType implements Types::EntityContextualPermissionsType implements Types::EntityPermissionsType implements Types::ExposesPermissionsType implements Types::HasEntityBreadcrumbs - implements Types::HasDefaultTimestampsType implements Types::HasHarvestModificationStatusType implements Types::HasSchemaPropertiesType implements Types::HasDOIType diff --git a/app/graphql/types/collection_attribution_type.rb b/app/graphql/types/collection_attribution_type.rb index 7107c529..e7850aba 100644 --- a/app/graphql/types/collection_attribution_type.rb +++ b/app/graphql/types/collection_attribution_type.rb @@ -2,7 +2,7 @@ module Types # @see CollectionAttribution - class CollectionAttributionType < Types::AbstractModel + class CollectionAttributionType < Types::BaseModel description <<~TEXT Attributions for collections. TEXT diff --git a/app/graphql/types/collection_contribution_type.rb b/app/graphql/types/collection_contribution_type.rb index c614e99a..b06341b2 100644 --- a/app/graphql/types/collection_contribution_type.rb +++ b/app/graphql/types/collection_contribution_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class CollectionContributionType < Types::AbstractModel + class CollectionContributionType < Types::BaseModel implements Types::ContributionType description "A contribution to a collection" diff --git a/app/graphql/types/collection_type.rb b/app/graphql/types/collection_type.rb index 94078a48..f0adceb4 100644 --- a/app/graphql/types/collection_type.rb +++ b/app/graphql/types/collection_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class CollectionType < Types::AbstractModel + class CollectionType < Types::BaseModel implements Types::AccessibleType implements Types::AttributableType implements Types::EntityType diff --git a/app/graphql/types/common_permissions_type.rb b/app/graphql/types/common_permissions_type.rb deleted file mode 100644 index 53c9f282..00000000 --- a/app/graphql/types/common_permissions_type.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Types - module CommonPermissionsType - include Types::BaseInterface - - description <<~TEXT - Common permissions shared on most models. - TEXT - - expose_authorization_rule :update?, <<~TEXT - Whether the current user has permission to update this record. - TEXT - - expose_authorization_rule :destroy?, <<~TEXT - Whether the current user has permission to destroy this record. - TEXT - end -end diff --git a/app/graphql/types/community_type.rb b/app/graphql/types/community_type.rb index dbe52f62..c87511cf 100644 --- a/app/graphql/types/community_type.rb +++ b/app/graphql/types/community_type.rb @@ -2,7 +2,7 @@ module Types # A GraphQL representation of a {Community}. - class CommunityType < Types::AbstractModel + class CommunityType < Types::BaseModel implements Types::AccessibleType implements Types::EntityType implements Types::HarvestTargetType diff --git a/app/graphql/types/contextual_permission_type.rb b/app/graphql/types/contextual_permission_type.rb index f2224d76..c7324a0f 100644 --- a/app/graphql/types/contextual_permission_type.rb +++ b/app/graphql/types/contextual_permission_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class ContextualPermissionType < Types::AbstractModel + class ContextualPermissionType < Types::BaseModel implements Types::ExposesPermissionsType description "A contextual permission for a user, role, and entity" diff --git a/app/graphql/types/contribution_role_configuration_type.rb b/app/graphql/types/contribution_role_configuration_type.rb index 7483f0e3..72fa0a50 100644 --- a/app/graphql/types/contribution_role_configuration_type.rb +++ b/app/graphql/types/contribution_role_configuration_type.rb @@ -2,7 +2,7 @@ module Types # @see ContributionRoleConfiguration - class ContributionRoleConfigurationType < Types::AbstractModel + class ContributionRoleConfigurationType < Types::BaseModel description <<~TEXT Configuration for the controlled vocabulary used for contribution roles on a given `source`. TEXT diff --git a/app/graphql/types/contributor_attribution_type.rb b/app/graphql/types/contributor_attribution_type.rb index bc7e4947..7bfb19d6 100644 --- a/app/graphql/types/contributor_attribution_type.rb +++ b/app/graphql/types/contributor_attribution_type.rb @@ -19,7 +19,7 @@ module ContributorAttributionType Similar to `Attribution`, but from the perspective of a `Contributor`. TEXT - field :entity_slug, Types::SlugType, null: false do + field :entity_slug, Support::GQL::SlugType, null: false do description <<~TEXT The slug for the entity. TEXT diff --git a/app/graphql/types/contributor_base_type.rb b/app/graphql/types/contributor_base_type.rb index b3681f29..f117531c 100644 --- a/app/graphql/types/contributor_base_type.rb +++ b/app/graphql/types/contributor_base_type.rb @@ -14,7 +14,8 @@ module ContributorBaseType TEXT implements Types::HasHarvestModificationStatusType - implements ::Types::SluggableType + implements ::Support::GQL::CommonPermissionsType + implements ::Support::GQL::SluggableType field :kind, Types::ContributorKindType, null: false @@ -107,7 +108,66 @@ module ContributorBaseType TEXT end + field :claimed, Boolean, null: false, method: :claimed? do + description <<~TEXT + Whether this contributor has been claimed by a user. + TEXT + end + + field :merge_busy, Boolean, null: false, method: :merge_busy? do + description <<~TEXT + Whether this contributor is currently involved in an active merge as either a source or target. + TEXT + end + + field :merge_source_status, Types::ContributorMergeSourceStatusType, null: false do + description <<~TEXT + The status of this contributor in the context of being a merge source. + TEXT + end + + field :merge_target, self, null: true do + description <<~TEXT + The target of the merge, if available. + TEXT + end + + field :merge_target_status, Types::ContributorMergeTargetStatusType, null: false do + description <<~TEXT + The status of this contributor in the context of being a merge target. + TEXT + end + load_association! :contributor_user_link, as: :user_link + load_association! :merge_target + + expose_authorization_rule :claim?, <<~TEXT + Whether the current user has the ability to claim this contributor profile as their own. + + This requires both that the user has permission to manage the system broadly, + and that they do not already have a contributor profile linked to their account. + + It also requires the contributor to be unclaimed. + + It is associated with the `contributorClaim` mutation. + TEXT + + expose_authorization_rule :link_user?, <<~TEXT + Whether the current user has the ability to link this contributor profile to any user account. + + This differs from `canClaim` in that it does not require the contributor to be unclaimed, + and can specify the user account to link to. + + It is associated with the `contributorUserLinkUpsert` mutation. + TEXT + + expose_authorization_rule :merge_source?, <<~TEXT + Whether the current user has the ability to use this contributor profile as a source in a merge operation. + TEXT + + expose_authorization_rule :merge_target?, <<~TEXT + Whether the current user has the ability to use this contributor profile as a target in a merge operation. + TEXT # @return [] def links diff --git a/app/graphql/types/contributor_collection_attribution_type.rb b/app/graphql/types/contributor_collection_attribution_type.rb index 091b1df3..3065b1d4 100644 --- a/app/graphql/types/contributor_collection_attribution_type.rb +++ b/app/graphql/types/contributor_collection_attribution_type.rb @@ -2,7 +2,7 @@ module Types # @see ContributorCollectionAttribution - class ContributorCollectionAttributionType < Types::AbstractModel + class ContributorCollectionAttributionType < Types::BaseModel description <<~TEXT A specific attribution on a `Collection`. TEXT diff --git a/app/graphql/types/contributor_item_attribution_type.rb b/app/graphql/types/contributor_item_attribution_type.rb index e973a476..10831991 100644 --- a/app/graphql/types/contributor_item_attribution_type.rb +++ b/app/graphql/types/contributor_item_attribution_type.rb @@ -2,7 +2,7 @@ module Types # @see ContributorItemAttribution - class ContributorItemAttributionType < Types::AbstractModel + class ContributorItemAttributionType < Types::BaseModel description <<~TEXT A specific attribution on a `Item`. TEXT diff --git a/app/graphql/types/contributor_merge_source_status_type.rb b/app/graphql/types/contributor_merge_source_status_type.rb new file mode 100644 index 00000000..9d8a6f79 --- /dev/null +++ b/app/graphql/types/contributor_merge_source_status_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + class ContributorMergeSourceStatusType < Types::BaseEnum + description <<~TEXT + The merge status of a `Contributor` in the context of being a merge source. + + Merging contributors happens in the background, so this + is useful to display when a contributor is in the process of being merged, + or if a merge has been completed but the source contributor has not yet been + deleted. + TEXT + + value "UNMERGED", value: "unmerged" do + description <<~TEXT + The contributor is not currently being merged to another. + TEXT + end + + value "MERGING", value: "merging" do + description <<~TEXT + The contributor is in the process of being merged to another. + TEXT + end + + value "MERGED", value: "merged" do + description <<~TEXT + The contributor has been merged and is awaiting deletion. + TEXT + end + end +end diff --git a/app/graphql/types/contributor_merge_target_status_type.rb b/app/graphql/types/contributor_merge_target_status_type.rb new file mode 100644 index 00000000..a719e29c --- /dev/null +++ b/app/graphql/types/contributor_merge_target_status_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + class ContributorMergeTargetStatusType < Types::BaseEnum + description <<~TEXT + The merge status of a `Contributor` in the context of being a merge target. + TEXT + + value "INACTIVE", value: "inactive" do + description <<~TEXT + The contributor is not currently the target of a merge. + TEXT + end + + value "ACTIVE", value: "active" do + description <<~TEXT + The contributor is currently the target of one or more merge(s). + TEXT + end + end +end diff --git a/app/graphql/types/contributor_permission_grid_type.rb b/app/graphql/types/contributor_permission_grid_type.rb new file mode 100644 index 00000000..de87ba52 --- /dev/null +++ b/app/graphql/types/contributor_permission_grid_type.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Types + # @see Roles::ContributorPermissionGrid + class ContributorPermissionGridType < Types::BaseObject + implements Types::PermissionGridType + implements Types::CRUDPermissionGridType + + description <<~TEXT + A grid of permissions for managing contributors in Meru. + + Contributors are a "global" record, in that they exist + outside of the entity hierarchy. + + `update` permissions for contributors can also be granted + by assigning that contributor to a user. + TEXT + + field :claim, Boolean, null: false do + description <<~TEXT + Whether or not a user with this permission can claim a contributor. + + This is distinct from creating or updating in that it only + applies to unclaimed contributors contributes the following auth results: + + - `Contributor.canClaim` + - `User.canClaimContributor` + + See `Mutations.contributorClaim` for more details. + TEXT + end + + field :merge, Boolean, null: false do + description <<~TEXT + Whether or not a user with this permission can merge contributor records together. + + This is distinct from `update` in that it only applies to the `contributorMerge` permission, + and feeds into the `canMergeSource` and `canMergeTarget` permissions on `Contributor`. + TEXT + end + end +end diff --git a/app/graphql/types/contributor_user_link_type.rb b/app/graphql/types/contributor_user_link_type.rb index 747536df..8fb9ece5 100644 --- a/app/graphql/types/contributor_user_link_type.rb +++ b/app/graphql/types/contributor_user_link_type.rb @@ -2,7 +2,7 @@ module Types # @see ContributorUserLink - class ContributorUserLinkType < Types::AbstractModel + class ContributorUserLinkType < Types::BaseModel description <<~TEXT A link between a `Contributor` and a `User`, indicating that the user is represented within Meru as that record. diff --git a/app/graphql/types/controlled_vocabulary_item_type.rb b/app/graphql/types/controlled_vocabulary_item_type.rb index e13f8b76..7dd864ef 100644 --- a/app/graphql/types/controlled_vocabulary_item_type.rb +++ b/app/graphql/types/controlled_vocabulary_item_type.rb @@ -2,7 +2,7 @@ module Types # @see ControlledVocabularyItem - class ControlledVocabularyItemType < Types::AbstractModel + class ControlledVocabularyItemType < Types::BaseModel description <<~TEXT An individual term within a `ControlledVocabulary`. TEXT diff --git a/app/graphql/types/controlled_vocabulary_source_type.rb b/app/graphql/types/controlled_vocabulary_source_type.rb index 58e8255b..0874316d 100644 --- a/app/graphql/types/controlled_vocabulary_source_type.rb +++ b/app/graphql/types/controlled_vocabulary_source_type.rb @@ -2,7 +2,7 @@ module Types # @see ControlledVocabularySource - class ControlledVocabularySourceType < Types::AbstractModel + class ControlledVocabularySourceType < Types::BaseModel description <<~TEXT A system-wide configuration that determines which `ControlledVocabulary` satisfies a desired `provides` value in schemas. diff --git a/app/graphql/types/controlled_vocabulary_type.rb b/app/graphql/types/controlled_vocabulary_type.rb index 89fdd724..515c1802 100644 --- a/app/graphql/types/controlled_vocabulary_type.rb +++ b/app/graphql/types/controlled_vocabulary_type.rb @@ -2,7 +2,7 @@ module Types # @see ControlledVocabulary - class ControlledVocabularyType < Types::AbstractModel + class ControlledVocabularyType < Types::BaseModel description <<~TEXT A set of terms that can be selected in schemas. diff --git a/app/graphql/types/depositor_agreement_transition_type.rb b/app/graphql/types/depositor_agreement_transition_type.rb index 03fdba32..a175caba 100644 --- a/app/graphql/types/depositor_agreement_transition_type.rb +++ b/app/graphql/types/depositor_agreement_transition_type.rb @@ -4,7 +4,7 @@ module Types # @see DepositorAgreementTransition # @see ::Types::DepositorAgreementTransitionConnectionType # @see ::Types::DepositorAgreementTransitionEdgeType - class DepositorAgreementTransitionType < Types::AbstractModel + class DepositorAgreementTransitionType < Types::BaseModel description <<~TEXT A transition for a `DepositorAgreement`. TEXT diff --git a/app/graphql/types/depositor_agreement_type.rb b/app/graphql/types/depositor_agreement_type.rb index f4ad6aa0..406e9ed4 100644 --- a/app/graphql/types/depositor_agreement_type.rb +++ b/app/graphql/types/depositor_agreement_type.rb @@ -4,7 +4,7 @@ module Types # @see DepositorAgreement # @see ::Types::DepositorAgreementConnectionType # @see ::Types::DepositorAgreementEdgeType - class DepositorAgreementType < Types::AbstractModel + class DepositorAgreementType < Types::BaseModel description <<~TEXT The record of an agreement accepted by a depositor for a given submission target. TEXT diff --git a/app/graphql/types/depositor_request_transition_type.rb b/app/graphql/types/depositor_request_transition_type.rb index 856471e1..9540aa6a 100644 --- a/app/graphql/types/depositor_request_transition_type.rb +++ b/app/graphql/types/depositor_request_transition_type.rb @@ -4,7 +4,7 @@ module Types # @see DepositorRequestTransition # @see ::Types::DepositorRequestTransitionConnectionType # @see ::Types::DepositorRequestTransitionEdgeType - class DepositorRequestTransitionType < Types::AbstractModel + class DepositorRequestTransitionType < Types::BaseModel description <<~TEXT A transition for a `DepositorRequest`. TEXT diff --git a/app/graphql/types/depositor_request_type.rb b/app/graphql/types/depositor_request_type.rb index e0a2fd0c..91c15478 100644 --- a/app/graphql/types/depositor_request_type.rb +++ b/app/graphql/types/depositor_request_type.rb @@ -4,7 +4,7 @@ module Types # @see DepositorRequest # @see ::Types::DepositorRequestConnectionType # @see ::Types::DepositorRequestEdgeType - class DepositorRequestType < Types::AbstractModel + class DepositorRequestType < Types::BaseModel description <<~TEXT A request for depositor access to a given submission target. TEXT diff --git a/app/graphql/types/entity_base_type.rb b/app/graphql/types/entity_base_type.rb index cc24aab5..1bb63734 100644 --- a/app/graphql/types/entity_base_type.rb +++ b/app/graphql/types/entity_base_type.rb @@ -9,7 +9,7 @@ module EntityBaseType but no ability to traverse the hierarchy. TEXT - implements ::Types::SluggableType + implements ::Support::GQL::SluggableType field :title, String, null: false do description <<~TEXT diff --git a/app/graphql/types/entity_link_type.rb b/app/graphql/types/entity_link_type.rb index 6614538f..a84330c6 100644 --- a/app/graphql/types/entity_link_type.rb +++ b/app/graphql/types/entity_link_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class EntityLinkType < Types::AbstractModel + class EntityLinkType < Types::BaseModel description "A link between different entities" implements Types::OrderingEntryableType diff --git a/app/graphql/types/entity_permission_grid_type.rb b/app/graphql/types/entity_permission_grid_type.rb index fa5af71d..0b8a442a 100644 --- a/app/graphql/types/entity_permission_grid_type.rb +++ b/app/graphql/types/entity_permission_grid_type.rb @@ -8,8 +8,19 @@ class EntityPermissionGridType < Types::BaseObject description "A grid of permissions for various hierarchical entity scopes." - field :manage_access, Boolean, null: false + field :manage_access, Boolean, null: false do + description <<~TEXT + Whether the user can manage access to entities at this scope. + TEXT + end - field :assets, Types::AssetPermissionGridType, null: false + field :assets, Types::AssetPermissionGridType, null: false do + description <<~TEXT + Permissions related to managing assets associated with the attached entity. + + This is slated for deprecation in a future release. Instead, permissions for + assets will be determined by the `update` permission on the entity. + TEXT + end end end diff --git a/app/graphql/types/entity_select_option_type.rb b/app/graphql/types/entity_select_option_type.rb index 6112d674..bab952c0 100644 --- a/app/graphql/types/entity_select_option_type.rb +++ b/app/graphql/types/entity_select_option_type.rb @@ -11,7 +11,7 @@ class EntitySelectOptionType < Types::BaseObject field :value, ID, null: false, method: :to_schematic_referent_value - field :slug, Types::SlugType, null: false, method: :system_slug + field :slug, Support::GQL::SlugType, null: false, method: :system_slug field :kind, Types::EntityKindType, null: false, method: :to_schematic_referent_kind diff --git a/app/graphql/types/entity_type.rb b/app/graphql/types/entity_type.rb index 6a6e3c87..eab5b79a 100644 --- a/app/graphql/types/entity_type.rb +++ b/app/graphql/types/entity_type.rb @@ -5,8 +5,10 @@ module EntityType include Types::BaseInterface implements ::GraphQL::Types::Relay::Node + implements ::Support::GQL::CommonPermissionsType + implements ::Support::GQL::SluggableType + implements ::Types::AccessibleType - implements ::Types::CommonPermissionsType implements ::Types::EntityBaseType implements ::Types::EntityContextualPermissionsType implements ::Types::EntityPermissionsType @@ -14,14 +16,15 @@ module EntityType implements ::Types::HasEntityBreadcrumbs implements ::Types::HasSchemaPropertiesType implements ::Types::SearchableType - implements ::Types::SluggableType - description "An entity that exists in the hierarchy." + description <<~TEXT + An entity that exists in the hierarchy. + TEXT field :announcement, Types::AnnouncementType, null: true do description "Look up an announcement for this entity by slug" - argument :slug, SlugType, required: true + argument :slug, ::Support::GQL::SlugType, required: true end field :announcements, resolver: Resolvers::AnnouncementResolver do @@ -81,7 +84,7 @@ module EntityType field :ordering_for_schema, Types::OrderingType, null: true do description "Look up an ordering that is set up to handle a specific schema." - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description "This should be of the `namespace:identifier` format." end end diff --git a/app/graphql/types/filter_input_object.rb b/app/graphql/types/filter_input_object.rb deleted file mode 100644 index 9e270150..00000000 --- a/app/graphql/types/filter_input_object.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module Types - # @abstract For input objects that should automatically become hashes. - class FilterInputObject < Types::BaseInputObject - # @return [Filtering::FilterScope, nil] - def prepare - options = to_h.symbolize_keys.compact.presence - - return nil if options.nil? - - self.class.filter_scope.new(**options) - end - - class << self - attr_reader :filter_scope - - # @param [Class] filter_scope - # @return [void] - def inherit_from!(filter_scope) - @filter_scope = filter_scope - - filter_scope.arguments.each do |key, dry_type| - typing = dry_type.gql_typing - - input_key = typing.input_key_for key - - opts = typing.argument_options - - opts[:as] = key.to_sym - - type = opts.delete :type - - argument input_key, type, **opts - end - - model = filter_scope.model_klass - - graphql_name filter_scope.input_object_name - - description <<~TEXT - Filters for #{model.model_name}. - TEXT - end - end - end -end diff --git a/app/graphql/types/filtering.rb b/app/graphql/types/filtering.rb index 18576dc0..6a7dd1c6 100644 --- a/app/graphql/types/filtering.rb +++ b/app/graphql/types/filtering.rb @@ -1,38 +1,8 @@ # frozen_string_literal: true module Types - module Filtering - PATTERN = /\A(?\w+)FilterInput\z/ - - class << self - def accept!(klass) - name = klass.graphql_name.to_sym - - remove_const name if const_defined?(name) - - const_set name, klass - end - - def const_missing(const_name) - case const_name - when PATTERN - model_name = Regexp.last_match[:model_name] - - model_scope = model_name.pluralize - - scope_name = "::Filtering::Scopes::#{model_scope}" - - scope_klass = scope_name.safe_constantize - - return super unless scope_klass - - scope_klass.input_object - else - # :nocov: - super - # :nocov: - end - end - end - end + # Namespace for {::Filtering}-related GraphQL types. + # + # @see ::Types::FilterScopeInputObject + module Filtering; end end diff --git a/app/graphql/types/filtering/contributor_filter_input_type.rb b/app/graphql/types/filtering/contributor_filter_input_type.rb new file mode 100644 index 00000000..fec3443d --- /dev/null +++ b/app/graphql/types/filtering/contributor_filter_input_type.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::Contributors + class ContributorFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `Contributor` records. + TEXT + + inherit_from!(::Filtering::Scopes::Contributors) + + argument :name_search, ::Support::GQL::FullTextSearchQueryInputType, required: false do + description <<~TEXT + Perform a full-text search with the provided query. + TEXT + end + + argument :unclaimed, ::GraphQL::Types::Boolean, required: false do + description <<~TEXT + Whether to include only contributors that have not been claimed by a user. + TEXT + end + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/controlled_vocabulary_filter_input_type.rb b/app/graphql/types/filtering/controlled_vocabulary_filter_input_type.rb new file mode 100644 index 00000000..48b830ff --- /dev/null +++ b/app/graphql/types/filtering/controlled_vocabulary_filter_input_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::ControlledVocabularies + class ControlledVocabularyFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `ControlledVocabulary` records. + TEXT + + inherit_from!(::Filtering::Scopes::ControlledVocabularies) + + argument :namespace, ::GraphQL::Types::String, required: false do + description <<~TEXT + Filter by namespace. + TEXT + end + + argument :identifier, ::GraphQL::Types::String, required: false do + description <<~TEXT + Filter by identifier. + TEXT + end + + argument :provides, ::GraphQL::Types::String, required: false do + description <<~TEXT + Filter by provides. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/controlled_vocabulary_source_filter_input_type.rb b/app/graphql/types/filtering/controlled_vocabulary_source_filter_input_type.rb new file mode 100644 index 00000000..8b00e2eb --- /dev/null +++ b/app/graphql/types/filtering/controlled_vocabulary_source_filter_input_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::ControlledVocabularySources + class ControlledVocabularySourceFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `ControlledVocabularySource` records. + TEXT + + inherit_from!(::Filtering::Scopes::ControlledVocabularySources) + + argument :unsatisfied, ::GraphQL::Types::Boolean, required: false do + description <<~TEXT + Fetch only sources that remain unsatisfied. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/depositor_request_filter_input_type.rb b/app/graphql/types/filtering/depositor_request_filter_input_type.rb new file mode 100644 index 00000000..8859ae4a --- /dev/null +++ b/app/graphql/types/filtering/depositor_request_filter_input_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::DepositorRequests + class DepositorRequestFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `DepositorRequest` records. + TEXT + + inherit_from!(::Filtering::Scopes::DepositorRequests) + + argument :in_state, [::Types::DepositorRequestStateType, { null: false }], required: false do + description <<~TEXT + Filter by in state. + TEXT + end + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/harvest_message_filter_input_type.rb b/app/graphql/types/filtering/harvest_message_filter_input_type.rb new file mode 100644 index 00000000..00b35513 --- /dev/null +++ b/app/graphql/types/filtering/harvest_message_filter_input_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::HarvestMessages + class HarvestMessageFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `HarvestMessage` records. + TEXT + + inherit_from!(::Filtering::Scopes::HarvestMessages) + + argument :severity, ::Types::HarvestMessageLevelType, required: false, default_value: "info", replace_null_with_default: true do + description <<~TEXT + Filter by severity. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/harvest_set_filter_input_type.rb b/app/graphql/types/filtering/harvest_set_filter_input_type.rb new file mode 100644 index 00000000..489af072 --- /dev/null +++ b/app/graphql/types/filtering/harvest_set_filter_input_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::HarvestSets + class HarvestSetFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `HarvestSet` records. + TEXT + + inherit_from!(::Filtering::Scopes::HarvestSets) + + argument :identifier, ::GraphQL::Types::String, required: false do + description <<~TEXT + Filter by identifier. + TEXT + end + + argument :name, ::GraphQL::Types::String, required: false do + description <<~TEXT + Filter by name. + TEXT + end + + argument :prefix, ::GraphQL::Types::String, required: false do + description <<~TEXT + Filter by prefix. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/item_filter_input_type.rb b/app/graphql/types/filtering/item_filter_input_type.rb new file mode 100644 index 00000000..2dfcf472 --- /dev/null +++ b/app/graphql/types/filtering/item_filter_input_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::Items + class ItemFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `Item` records. + TEXT + + inherit_from!(::Filtering::Scopes::Items) + + argument :include_drafts, ::GraphQL::Types::Boolean, required: false, default_value: false, replace_null_with_default: true do + description <<~TEXT + Whether to include items that are in draft state (i.e. items that are associated with a submission). + TEXT + end + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/submission_comment_filter_input_type.rb b/app/graphql/types/filtering/submission_comment_filter_input_type.rb new file mode 100644 index 00000000..503d030f --- /dev/null +++ b/app/graphql/types/filtering/submission_comment_filter_input_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::SubmissionComments + class SubmissionCommentFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `SubmissionComment` records. + TEXT + + inherit_from!(::Filtering::Scopes::SubmissionComments) + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/submission_filter_input_type.rb b/app/graphql/types/filtering/submission_filter_input_type.rb new file mode 100644 index 00000000..a7f696dc --- /dev/null +++ b/app/graphql/types/filtering/submission_filter_input_type.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::Submissions + class SubmissionFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `Submission` records. + TEXT + + inherit_from!(::Filtering::Scopes::Submissions) + + argument :prefix, ::GraphQL::Types::String, required: false do + description <<~TEXT + Perform a full-text search to approximately match the provided string. + TEXT + end + + argument :query, ::GraphQL::Types::String, required: false do + description <<~TEXT + Perform a full-text search to approximately match the provided string. + TEXT + end + + argument :parent_entity_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::EntityType, as: :parent_entity, required: false do + description <<~TEXT + Filter submissions to only those with the given parent entity(ies). + TEXT + end + + argument :schema_version_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::SchemaVersionType, as: :schema_version, required: false do + description <<~TEXT + Filter submissions to only those with the given schema version(s). + TEXT + end + + argument :in_state, [::Types::SubmissionStateType, { null: false }], required: false do + description <<~TEXT + Filter by in state. + TEXT + end + + argument :submission_target_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::SubmissionTargetType, as: :submission_target, required: false do + description <<~TEXT + Filter submissions to only those with the given submission target(s). + TEXT + end + + argument :user_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::UserType, as: :user, required: false do + description <<~TEXT + Filter submissions to only those created by the given user(s). + TEXT + end + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/submission_review_filter_input_type.rb b/app/graphql/types/filtering/submission_review_filter_input_type.rb new file mode 100644 index 00000000..9bfe71fd --- /dev/null +++ b/app/graphql/types/filtering/submission_review_filter_input_type.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::SubmissionReviews + class SubmissionReviewFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `SubmissionReview` records. + TEXT + + inherit_from!(::Filtering::Scopes::SubmissionReviews) + + argument :in_state, [::Types::SubmissionReviewStateType, { null: false }], required: false do + description <<~TEXT + Filter by in state. + TEXT + end + + argument :submission_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::SubmissionType, as: :submission, required: false do + description <<~TEXT + Filter by multiple Submission. + TEXT + end + + argument :user_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::UserType, as: :user, required: false do + description <<~TEXT + Filter by multiple User. + TEXT + end + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/submission_target_filter_input_type.rb b/app/graphql/types/filtering/submission_target_filter_input_type.rb new file mode 100644 index 00000000..4cd624a8 --- /dev/null +++ b/app/graphql/types/filtering/submission_target_filter_input_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::SubmissionTargets + class SubmissionTargetFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `SubmissionTarget` records. + TEXT + + inherit_from!(::Filtering::Scopes::SubmissionTargets) + + argument :in_state, [::Types::SubmissionTargetStateType, { null: false }], required: false do + description <<~TEXT + Filter by in state. + TEXT + end + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/submission_target_reviewer_filter_input_type.rb b/app/graphql/types/filtering/submission_target_reviewer_filter_input_type.rb new file mode 100644 index 00000000..e1db75bb --- /dev/null +++ b/app/graphql/types/filtering/submission_target_reviewer_filter_input_type.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::SubmissionTargetReviewers + class SubmissionTargetReviewerFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `SubmissionTargetReviewer` records. + TEXT + + inherit_from!(::Filtering::Scopes::SubmissionTargetReviewers) + + argument :submission_target_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::SubmissionTargetType, as: :submission_target, required: false do + description <<~TEXT + Filter by the submission target. + TEXT + end + + argument :user_ids, [::GraphQL::Types::ID, { null: false }], loads: ::Types::UserType, as: :user, required: false do + description <<~TEXT + Filter by the associated user. + TEXT + end + + argument :created_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `created_at` with time constraints. + TEXT + end + + argument :updated_at, ::Support::GQL::FilterMatchTimeInputType, required: false do + description <<~TEXT + Filter the model's `updated_at` with time constraints. + TEXT + end + end + end +end diff --git a/app/graphql/types/filtering/user_filter_input_type.rb b/app/graphql/types/filtering/user_filter_input_type.rb new file mode 100644 index 00000000..967b987c --- /dev/null +++ b/app/graphql/types/filtering/user_filter_input_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see ::Filtering::Scopes::Users + class UserFilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `User` records. + TEXT + + inherit_from!(::Filtering::Scopes::Users) + + argument :email, ::GraphQL::Types::String, required: false do + description <<~TEXT + Look for an exact email match. + TEXT + end + end + end +end diff --git a/app/graphql/types/global_access_control_list_type.rb b/app/graphql/types/global_access_control_list_type.rb index b18e0586..8069db9b 100644 --- a/app/graphql/types/global_access_control_list_type.rb +++ b/app/graphql/types/global_access_control_list_type.rb @@ -5,6 +5,46 @@ module Types class GlobalAccessControlListType < Types::BaseObject implements Types::ExposesPermissionsType - description "A global ACL" + description <<~TEXT + A global ACL that applies to a given role. + + Permissions defined in this ACL are not scoped to any particular entity. + TEXT + + field :admin, Types::AdminPermissionGridType, null: false do + description <<~TEXT + Permissions related to the admin section of Meru. + TEXT + end + + field :communities, Types::EntityPermissionGridType, null: false do + description <<~TEXT + Permissions related to communities, aka top-level entities. + TEXT + end + + field :contributors, Types::ContributorPermissionGridType, null: false do + description <<~TEXT + Permissions related to contributors. + TEXT + end + + field :roles, Types::RolePermissionGridType, null: false do + description <<~TEXT + Permissions related to managing role records in Meru. + TEXT + end + + field :settings, Types::SettingsPermissionGridType, null: false do + description <<~TEXT + Permissions related to managing global configuration settings in Meru. + TEXT + end + + field :users, Types::UserPermissionGridType, null: false do + description <<~TEXT + Permissions related to managing user records in Meru. + TEXT + end end end diff --git a/app/graphql/types/global_configuration_type.rb b/app/graphql/types/global_configuration_type.rb index b34a56cb..6149cf9b 100644 --- a/app/graphql/types/global_configuration_type.rb +++ b/app/graphql/types/global_configuration_type.rb @@ -15,6 +15,10 @@ class GlobalConfigurationType < Types::BaseObject TEXT end + field :contributors, Types::Settings::ContributorsSettingsType, null: false do + description "Settings specific to contributors on this installation." + end + field :depositing, Types::Settings::DepositingSettingsType, null: false do description "Settings specific to depositing to this installation." end diff --git a/app/graphql/types/harvest_attempt_entity_status_type.rb b/app/graphql/types/harvest_attempt_entity_status_type.rb index 6d0bd13c..60b2e058 100644 --- a/app/graphql/types/harvest_attempt_entity_status_type.rb +++ b/app/graphql/types/harvest_attempt_entity_status_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestAttemptEntityStatus - class HarvestAttemptEntityStatusType < Types::AbstractModel + class HarvestAttemptEntityStatusType < Types::BaseModel description <<~TEXT A progress report for entity data during a harvest attempt. TEXT diff --git a/app/graphql/types/harvest_attempt_record_status_type.rb b/app/graphql/types/harvest_attempt_record_status_type.rb index f0d7edb7..b315e421 100644 --- a/app/graphql/types/harvest_attempt_record_status_type.rb +++ b/app/graphql/types/harvest_attempt_record_status_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestAttemptRecordStatus - class HarvestAttemptRecordStatusType < Types::AbstractModel + class HarvestAttemptRecordStatusType < Types::BaseModel description <<~TEXT A progress report for record data during a harvest attempt. TEXT diff --git a/app/graphql/types/harvest_attempt_type.rb b/app/graphql/types/harvest_attempt_type.rb index 6d96773a..7a4aa2c6 100644 --- a/app/graphql/types/harvest_attempt_type.rb +++ b/app/graphql/types/harvest_attempt_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestAttempt - class HarvestAttemptType < Types::AbstractModel + class HarvestAttemptType < Types::BaseModel description <<~TEXT A record of a single attempt at harvesting. TEXT diff --git a/app/graphql/types/harvest_entity_type.rb b/app/graphql/types/harvest_entity_type.rb index adc13951..39ceefa2 100644 --- a/app/graphql/types/harvest_entity_type.rb +++ b/app/graphql/types/harvest_entity_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestEntity - class HarvestEntityType < Types::AbstractModel + class HarvestEntityType < Types::BaseModel description <<~TEXT A staged entity extracted from a `HarvestRecord`, that can then be associated to an actual entity by the diff --git a/app/graphql/types/harvest_error_type.rb b/app/graphql/types/harvest_error_type.rb index 677f5d2c..b4549345 100644 --- a/app/graphql/types/harvest_error_type.rb +++ b/app/graphql/types/harvest_error_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestError - class HarvestErrorType < Types::AbstractModel + class HarvestErrorType < Types::BaseModel description <<~TEXT An error that may occur during the harvesting process. TEXT diff --git a/app/graphql/types/harvest_mapping_type.rb b/app/graphql/types/harvest_mapping_type.rb index a7ef9f12..92d8ca95 100644 --- a/app/graphql/types/harvest_mapping_type.rb +++ b/app/graphql/types/harvest_mapping_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestMapping - class HarvestMappingType < Types::AbstractModel + class HarvestMappingType < Types::BaseModel description <<~TEXT A specific mapping to be used by a `HarvestSource`, possibly an optional `HarvestSet`, and other options diff --git a/app/graphql/types/harvest_message_type.rb b/app/graphql/types/harvest_message_type.rb index c4452b87..9ae0ec4e 100644 --- a/app/graphql/types/harvest_message_type.rb +++ b/app/graphql/types/harvest_message_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestMessage - class HarvestMessageType < Types::AbstractModel + class HarvestMessageType < Types::BaseModel description <<~TEXT A single log message recorded during some aspect of harvesting. TEXT diff --git a/app/graphql/types/harvest_metadata_mapping_type.rb b/app/graphql/types/harvest_metadata_mapping_type.rb index 6741605d..b734b70f 100644 --- a/app/graphql/types/harvest_metadata_mapping_type.rb +++ b/app/graphql/types/harvest_metadata_mapping_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestMetadataMapping - class HarvestMetadataMappingType < Types::AbstractModel + class HarvestMetadataMappingType < Types::BaseModel description <<~TEXT An advanced definition that allows mapping specific existing entities to act as parents for certain harvested entities, based on their metadata matching a specific mapping. diff --git a/app/graphql/types/harvest_record_type.rb b/app/graphql/types/harvest_record_type.rb index 2bcdf35f..735f216b 100644 --- a/app/graphql/types/harvest_record_type.rb +++ b/app/graphql/types/harvest_record_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestRecord - class HarvestRecordType < Types::AbstractModel + class HarvestRecordType < Types::BaseModel description <<~TEXT An object representing a single record in the `HarvestSource`. It can produce one or more harvest entities. diff --git a/app/graphql/types/harvest_set_type.rb b/app/graphql/types/harvest_set_type.rb index a3f00559..9c55c063 100644 --- a/app/graphql/types/harvest_set_type.rb +++ b/app/graphql/types/harvest_set_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestSet - class HarvestSetType < Types::AbstractModel + class HarvestSetType < Types::BaseModel description <<~TEXT The concept of a "set" within a given `HarvestSource`. It can be used in order to fetch a subset of data with a `HarvestMapping`. These are not created directly in Meru, but diff --git a/app/graphql/types/harvest_source_type.rb b/app/graphql/types/harvest_source_type.rb index b528d33e..23ac86e7 100644 --- a/app/graphql/types/harvest_source_type.rb +++ b/app/graphql/types/harvest_source_type.rb @@ -2,7 +2,7 @@ module Types # @see HarvestSource - class HarvestSourceType < Types::AbstractModel + class HarvestSourceType < Types::BaseModel description <<~TEXT A source from which to harvest entities. diff --git a/app/graphql/types/harvest_target_type.rb b/app/graphql/types/harvest_target_type.rb index 5919ebc4..5699521b 100644 --- a/app/graphql/types/harvest_target_type.rb +++ b/app/graphql/types/harvest_target_type.rb @@ -12,7 +12,7 @@ module HarvestTargetType TEXT implements Types::EntityBaseType - implements ::Types::SluggableType + implements ::Support::GQL::SluggableType field :harvest_target_kind, Types::HarvestTargetKindType, null: false do description <<~TEXT diff --git a/app/graphql/types/has_default_timestamps_type.rb b/app/graphql/types/has_default_timestamps_type.rb deleted file mode 100644 index 2a99db19..00000000 --- a/app/graphql/types/has_default_timestamps_type.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Types - # @note This interface exists to be able to DRY up adding timestamps - # to types that do not inherit from {Types::AbstractModelType}. - module HasDefaultTimestampsType - include Types::BaseInterface - - description <<~TEXT - Automatically-set timestamps present on most real models in the system. - TEXT - - field :created_at, GraphQL::Types::ISO8601DateTime, null: false do - description "The date this record was created within the API." - end - - field :created_on, GraphQL::Types::ISO8601Date, null: false do - description "The date this record was created within the API (date only)." - end - - field :updated_at, GraphQL::Types::ISO8601DateTime, null: false do - description "The date this record was last updated within the API." - end - - field :updated_on, GraphQL::Types::ISO8601Date, null: false do - description "The date this record was last updated within the API (date only)." - end - - def created_on = object.created_at.to_date - - def updated_on = object.updated_at.to_date - end -end diff --git a/app/graphql/types/item_attribution_type.rb b/app/graphql/types/item_attribution_type.rb index 991d3e35..bda7ddcc 100644 --- a/app/graphql/types/item_attribution_type.rb +++ b/app/graphql/types/item_attribution_type.rb @@ -2,7 +2,7 @@ module Types # @see ItemAttribution - class ItemAttributionType < Types::AbstractModel + class ItemAttributionType < Types::BaseModel description <<~TEXT Attributions for items. TEXT diff --git a/app/graphql/types/item_contribution_type.rb b/app/graphql/types/item_contribution_type.rb index 66d38af2..b3f057f2 100644 --- a/app/graphql/types/item_contribution_type.rb +++ b/app/graphql/types/item_contribution_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class ItemContributionType < Types::AbstractModel + class ItemContributionType < Types::BaseModel implements Types::ContributionType description "A contribution to an item" diff --git a/app/graphql/types/item_type.rb b/app/graphql/types/item_type.rb index f0941b1b..64f6dbbc 100644 --- a/app/graphql/types/item_type.rb +++ b/app/graphql/types/item_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class ItemType < Types::AbstractModel + class ItemType < Types::BaseModel implements Types::AccessibleType implements Types::AttributableType implements Types::EntityType diff --git a/app/graphql/types/layouts/hero_layout_definition_type.rb b/app/graphql/types/layouts/hero_layout_definition_type.rb index 5247ca6c..1a7ba959 100644 --- a/app/graphql/types/layouts/hero_layout_definition_type.rb +++ b/app/graphql/types/layouts/hero_layout_definition_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::HeroDefinition - class HeroLayoutDefinitionType < AbstractModel + class HeroLayoutDefinitionType < ::Types::BaseModel implements ::Types::LayoutDefinitionType field :templates, [Types::AnyHeroTemplateDefinitionType, { null: false }], null: false do diff --git a/app/graphql/types/layouts/hero_layout_instance_type.rb b/app/graphql/types/layouts/hero_layout_instance_type.rb index 245698a0..ee8c5053 100644 --- a/app/graphql/types/layouts/hero_layout_instance_type.rb +++ b/app/graphql/types/layouts/hero_layout_instance_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::HeroInstance - class HeroLayoutInstanceType < AbstractModel + class HeroLayoutInstanceType < ::Types::BaseModel implements ::Types::LayoutInstanceType field :layout_definition, Types::Layouts::HeroLayoutDefinitionType, null: false do diff --git a/app/graphql/types/layouts/list_item_layout_definition_type.rb b/app/graphql/types/layouts/list_item_layout_definition_type.rb index 75d5a04e..ab5bda17 100644 --- a/app/graphql/types/layouts/list_item_layout_definition_type.rb +++ b/app/graphql/types/layouts/list_item_layout_definition_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::ListItemDefinition - class ListItemLayoutDefinitionType < AbstractModel + class ListItemLayoutDefinitionType < ::Types::BaseModel implements ::Types::LayoutDefinitionType field :templates, [Types::AnyListItemTemplateDefinitionType, { null: false }], null: false do diff --git a/app/graphql/types/layouts/list_item_layout_instance_type.rb b/app/graphql/types/layouts/list_item_layout_instance_type.rb index 57ddc8c1..1099043c 100644 --- a/app/graphql/types/layouts/list_item_layout_instance_type.rb +++ b/app/graphql/types/layouts/list_item_layout_instance_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::ListItemInstance - class ListItemLayoutInstanceType < AbstractModel + class ListItemLayoutInstanceType < ::Types::BaseModel implements ::Types::LayoutInstanceType field :layout_definition, Types::Layouts::ListItemLayoutDefinitionType, null: false do diff --git a/app/graphql/types/layouts/main_layout_definition_type.rb b/app/graphql/types/layouts/main_layout_definition_type.rb index 6c9bb9dc..8924e108 100644 --- a/app/graphql/types/layouts/main_layout_definition_type.rb +++ b/app/graphql/types/layouts/main_layout_definition_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::MainDefinition - class MainLayoutDefinitionType < AbstractModel + class MainLayoutDefinitionType < ::Types::BaseModel implements ::Types::LayoutDefinitionType field :templates, [Types::AnyMainTemplateDefinitionType, { null: false }], null: false do diff --git a/app/graphql/types/layouts/main_layout_instance_type.rb b/app/graphql/types/layouts/main_layout_instance_type.rb index 27b70a38..3ceac80f 100644 --- a/app/graphql/types/layouts/main_layout_instance_type.rb +++ b/app/graphql/types/layouts/main_layout_instance_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::MainInstance - class MainLayoutInstanceType < AbstractModel + class MainLayoutInstanceType < ::Types::BaseModel implements ::Types::LayoutInstanceType field :layout_definition, Types::Layouts::MainLayoutDefinitionType, null: false do diff --git a/app/graphql/types/layouts/metadata_layout_definition_type.rb b/app/graphql/types/layouts/metadata_layout_definition_type.rb index 0c1abc31..a0063a00 100644 --- a/app/graphql/types/layouts/metadata_layout_definition_type.rb +++ b/app/graphql/types/layouts/metadata_layout_definition_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::MetadataDefinition - class MetadataLayoutDefinitionType < AbstractModel + class MetadataLayoutDefinitionType < ::Types::BaseModel implements ::Types::LayoutDefinitionType field :templates, [Types::AnyMetadataTemplateDefinitionType, { null: false }], null: false do diff --git a/app/graphql/types/layouts/metadata_layout_instance_type.rb b/app/graphql/types/layouts/metadata_layout_instance_type.rb index 3ae09577..8ee358a2 100644 --- a/app/graphql/types/layouts/metadata_layout_instance_type.rb +++ b/app/graphql/types/layouts/metadata_layout_instance_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::MetadataInstance - class MetadataLayoutInstanceType < AbstractModel + class MetadataLayoutInstanceType < ::Types::BaseModel implements ::Types::LayoutInstanceType field :layout_definition, Types::Layouts::MetadataLayoutDefinitionType, null: false do diff --git a/app/graphql/types/layouts/navigation_layout_definition_type.rb b/app/graphql/types/layouts/navigation_layout_definition_type.rb index 03b381f5..23f76f82 100644 --- a/app/graphql/types/layouts/navigation_layout_definition_type.rb +++ b/app/graphql/types/layouts/navigation_layout_definition_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::NavigationDefinition - class NavigationLayoutDefinitionType < AbstractModel + class NavigationLayoutDefinitionType < ::Types::BaseModel implements ::Types::LayoutDefinitionType field :templates, [Types::AnyNavigationTemplateDefinitionType, { null: false }], null: false do diff --git a/app/graphql/types/layouts/navigation_layout_instance_type.rb b/app/graphql/types/layouts/navigation_layout_instance_type.rb index ce79116b..8b13a8c3 100644 --- a/app/graphql/types/layouts/navigation_layout_instance_type.rb +++ b/app/graphql/types/layouts/navigation_layout_instance_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::NavigationInstance - class NavigationLayoutInstanceType < AbstractModel + class NavigationLayoutInstanceType < ::Types::BaseModel implements ::Types::LayoutInstanceType field :layout_definition, Types::Layouts::NavigationLayoutDefinitionType, null: false do diff --git a/app/graphql/types/layouts/supplementary_layout_definition_type.rb b/app/graphql/types/layouts/supplementary_layout_definition_type.rb index 76d4592a..cc951621 100644 --- a/app/graphql/types/layouts/supplementary_layout_definition_type.rb +++ b/app/graphql/types/layouts/supplementary_layout_definition_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::SupplementaryDefinition - class SupplementaryLayoutDefinitionType < AbstractModel + class SupplementaryLayoutDefinitionType < ::Types::BaseModel implements ::Types::LayoutDefinitionType field :templates, [Types::AnySupplementaryTemplateDefinitionType, { null: false }], null: false do diff --git a/app/graphql/types/layouts/supplementary_layout_instance_type.rb b/app/graphql/types/layouts/supplementary_layout_instance_type.rb index 378b7bbd..d065ff09 100644 --- a/app/graphql/types/layouts/supplementary_layout_instance_type.rb +++ b/app/graphql/types/layouts/supplementary_layout_instance_type.rb @@ -3,7 +3,7 @@ module Types module Layouts # @see ::Layouts::SupplementaryInstance - class SupplementaryLayoutInstanceType < AbstractModel + class SupplementaryLayoutInstanceType < ::Types::BaseModel implements ::Types::LayoutInstanceType field :layout_definition, Types::Layouts::SupplementaryLayoutDefinitionType, null: false do diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 1881d240..9236980f 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -181,5 +181,9 @@ class MutationType < Types::BaseObject field :contributor_user_link_destroy, mutation: Mutations::ContributorUserLinkDestroy field :contributor_user_link_upsert, mutation: Mutations::ContributorUserLinkUpsert + + field :contributor_claim, mutation: Mutations::ContributorClaim + + field :contributor_merge, mutation: Mutations::ContributorMerge end end diff --git a/app/graphql/types/ordering_entry_type.rb b/app/graphql/types/ordering_entry_type.rb index 78dacefa..11c6245e 100644 --- a/app/graphql/types/ordering_entry_type.rb +++ b/app/graphql/types/ordering_entry_type.rb @@ -2,7 +2,7 @@ module Types # The GraphQL representation of an {OrderingEntry}. - class OrderingEntryType < Types::AbstractModel + class OrderingEntryType < Types::BaseModel description "An entry within an ordering, it can refer to an entity or an entity link" field :ordering, "Types::OrderingType", null: false, description: "The parent ordering" @@ -21,7 +21,7 @@ class OrderingEntryType < Types::AbstractModel TEXT end - field :entry_slug, Types::SlugType, null: true do + field :entry_slug, Support::GQL::SlugType, null: true do description <<~TEXT The delegated `slug` from the associated `entry`. diff --git a/app/graphql/types/ordering_type.rb b/app/graphql/types/ordering_type.rb index 89b88be5..8d722825 100644 --- a/app/graphql/types/ordering_type.rb +++ b/app/graphql/types/ordering_type.rb @@ -2,7 +2,7 @@ module Types # The GraphQL representation of an {Ordering}. - class OrderingType < Types::AbstractModel + class OrderingType < Types::BaseModel description "An ordering that belongs to an entity and arranges its children in a pre-configured way" implements Types::SearchableType diff --git a/app/graphql/types/organization_contributor_type.rb b/app/graphql/types/organization_contributor_type.rb index 50a36b05..041cf603 100644 --- a/app/graphql/types/organization_contributor_type.rb +++ b/app/graphql/types/organization_contributor_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class OrganizationContributorType < Types::AbstractModel + class OrganizationContributorType < Types::BaseModel implements Types::ContributorType description <<~TEXT diff --git a/app/graphql/types/page_direction_type.rb b/app/graphql/types/page_direction_type.rb deleted file mode 100644 index 1127c6c3..00000000 --- a/app/graphql/types/page_direction_type.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Types - class PageDirectionType < Types::BaseEnum - description "Determines the direction that page-number based pagination should flow" - - value "FORWARDS", value: :forwards - value "BACKWARDS", value: :backwards - end -end diff --git a/app/graphql/types/page_info_type.rb b/app/graphql/types/page_info_type.rb deleted file mode 100644 index 1e5cf0ce..00000000 --- a/app/graphql/types/page_info_type.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -module Types - # An override of the default Relay PageInfo type to add additional fields - # for page-based pagination. - class PageInfoType < Types::AbstractObjectType - include GraphQL::Types::Relay::PageInfoBehaviors - - field :page, Integer, null: true do - description "The page (if page-based pagination is supported and one was provided, does not introspect a value with cursor-based pagination)" - end - - field :page_count, Integer, null: true do - description "The total number of pages available to the connection (if page-based pagination supported and a page was provided)" - end - - field :per_page, Integer, null: true do - description "The number of edges/nodes per page (if page-based pagination supported and a page was provided)" - end - - field :total_count, Integer, null: false do - description "The total number of nodes available to this connection, constrained by applied filters (if any)" - end - - field :total_unfiltered_count, Integer, null: false do - description "The total number of nodes available to this connection, independent of any filters" - end - - # @return [Integer, nil] - def page - from_info :page - end - - # @return [Integer, nil] - def page_count - # :nocov: - return nil if per_page.nil? - # :nocov: - - size = total_count - - return 0 if size.zero? - - full_pages, rem = size.divmod per_page - - rem > 0 ? full_pages + 1 : full_pages - end - - # @return [Integer, nil] - def per_page - from_info :per_page - end - - # @return [Integer] - def total_count - from_connection_info(:total_count) do - object.items.count_from_subquery - end - end - - # @return [Integer] - def total_unfiltered_count - from_connection_info(:unfiltered_count) do - from_resolver(:unfiltered_count) { total_count } - end - end - - private - - # @param [Symbol] key - # @return [Object] - def from_connection_info(key) - object.context[:connection_info].then do |cinfo| - # :nocov: - cinfo&.__send__(key) || yield - # :nocov: - end - end - - # @param [Symbol] key - # @return [Object] - def from_info(key) - object.context[:pagination].then do |pagination| - if pagination.kind_of?(Hash) - pagination[key] - else - # :nocov: - object.arguments[key] - # :nocov: - end - end - end - - # @param [Symbol] method_name - # @return [Object] - def from_resolver(method_name, &) - object.context[:resolver].try(method_name).then do |value| - value.presence || yield - end - end - end -end diff --git a/app/graphql/types/paginated_type.rb b/app/graphql/types/paginated_type.rb deleted file mode 100644 index 09ffb672..00000000 --- a/app/graphql/types/paginated_type.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Types - module PaginatedType - include Types::BaseInterface - - description "Connections can be paginated by cursor or number." - - field :page_info, Types::PageInfoType, null: false, description: "Information to aid in pagination." - end -end diff --git a/app/graphql/types/permalink_type.rb b/app/graphql/types/permalink_type.rb index 22d43d46..d7646904 100644 --- a/app/graphql/types/permalink_type.rb +++ b/app/graphql/types/permalink_type.rb @@ -2,7 +2,7 @@ module Types # @see Permalink - class PermalinkType < Types::AbstractModel + class PermalinkType < Types::BaseModel description <<~TEXT A permalink is a persistant link to a resource with a human-readable URI. Each resource can have multiple permalinks, but only one can be marked as canonical. diff --git a/app/graphql/types/person_contributor_type.rb b/app/graphql/types/person_contributor_type.rb index 6b34591a..38f3029b 100644 --- a/app/graphql/types/person_contributor_type.rb +++ b/app/graphql/types/person_contributor_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class PersonContributorType < Types::AbstractModel + class PersonContributorType < Types::BaseModel implements Types::ContributorType description <<~TEXT diff --git a/app/graphql/types/queries_contrib.rb b/app/graphql/types/queries_contrib.rb index 0b86bb76..9e4096bb 100644 --- a/app/graphql/types/queries_contrib.rb +++ b/app/graphql/types/queries_contrib.rb @@ -11,13 +11,13 @@ module QueriesContrib field :collection_contribution, Types::CollectionContributionType, null: true do description "Look up a collection contribution by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :contributor, Types::ContributorType, null: true do description "Look up a contributor by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :contributor_lookup, Types::ContributorType, null: true do @@ -38,7 +38,7 @@ module QueriesContrib TEXT end - argument :order, Types::SimpleOrderType, required: true, default_value: "RECENT" do + argument :order, Support::GQL::SimpleOrderType, required: true, default_value: "RECENT" do description <<~TEXT For certain fields, the values are not guaranteed to be unique. In these instances, the *most recently* created contributor will be selected by default. If the first @@ -62,7 +62,7 @@ module QueriesContrib field :item_contribution, Types::ItemContributionType, null: true do description "Look up an item contribution by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end def collection_contribution(slug:) diff --git a/app/graphql/types/queries_controlled_vocabulary.rb b/app/graphql/types/queries_controlled_vocabulary.rb index 8a785a35..c3bceacb 100644 --- a/app/graphql/types/queries_controlled_vocabulary.rb +++ b/app/graphql/types/queries_controlled_vocabulary.rb @@ -14,7 +14,7 @@ module QueriesControlledVocabulary Retrieve a single `ControlledVocabulary` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_controlled_vocabulary_source.rb b/app/graphql/types/queries_controlled_vocabulary_source.rb index b485bfd7..1acd5c85 100644 --- a/app/graphql/types/queries_controlled_vocabulary_source.rb +++ b/app/graphql/types/queries_controlled_vocabulary_source.rb @@ -14,7 +14,7 @@ module QueriesControlledVocabularySource Retrieve a single `ControlledVocabularySource` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_depositor_request.rb b/app/graphql/types/queries_depositor_request.rb index ff5b9683..101bcf5b 100644 --- a/app/graphql/types/queries_depositor_request.rb +++ b/app/graphql/types/queries_depositor_request.rb @@ -14,7 +14,7 @@ module QueriesDepositorRequest Retrieve a single `DepositorRequest` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_entities.rb b/app/graphql/types/queries_entities.rb index 17c6c248..48518958 100644 --- a/app/graphql/types/queries_entities.rb +++ b/app/graphql/types/queries_entities.rb @@ -11,13 +11,13 @@ module QueriesEntities field :asset, Types::AssetType, null: true do description "Look up an asset by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :collection, Types::CollectionType, null: true do description "Look up a collection by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :communities, resolver: Resolvers::CommunityResolver do @@ -27,7 +27,7 @@ module QueriesEntities field :community, Types::CommunityType, null: true do description "Look up a community by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :community_by_title, Types::CommunityType, null: true do @@ -39,7 +39,7 @@ module QueriesEntities field :item, Types::ItemType, null: true do description "Look up an item by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end def asset(slug:) diff --git a/app/graphql/types/queries_harvest_attempt.rb b/app/graphql/types/queries_harvest_attempt.rb index 867a8e90..61785fd2 100644 --- a/app/graphql/types/queries_harvest_attempt.rb +++ b/app/graphql/types/queries_harvest_attempt.rb @@ -14,7 +14,7 @@ module QueriesHarvestAttempt Retrieve a single `HarvestAttempt` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_harvest_mapping.rb b/app/graphql/types/queries_harvest_mapping.rb index 4ebbf753..5a1b40ae 100644 --- a/app/graphql/types/queries_harvest_mapping.rb +++ b/app/graphql/types/queries_harvest_mapping.rb @@ -14,7 +14,7 @@ module QueriesHarvestMapping Retrieve a single `HarvestMapping` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_harvest_record.rb b/app/graphql/types/queries_harvest_record.rb index c22f6d39..17d60a3a 100644 --- a/app/graphql/types/queries_harvest_record.rb +++ b/app/graphql/types/queries_harvest_record.rb @@ -14,7 +14,7 @@ module QueriesHarvestRecord Retrieve a single `HarvestRecord` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_harvest_set.rb b/app/graphql/types/queries_harvest_set.rb index ac68116b..d1766ac6 100644 --- a/app/graphql/types/queries_harvest_set.rb +++ b/app/graphql/types/queries_harvest_set.rb @@ -16,7 +16,7 @@ module QueriesHarvestSet Retrieve a single `HarvestSet` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_harvest_source.rb b/app/graphql/types/queries_harvest_source.rb index bd06d2c1..cef14e23 100644 --- a/app/graphql/types/queries_harvest_source.rb +++ b/app/graphql/types/queries_harvest_source.rb @@ -14,7 +14,7 @@ module QueriesHarvestSource Retrieve a single `HarvestSource` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_permalink.rb b/app/graphql/types/queries_permalink.rb index 68e3498c..9bc6483a 100644 --- a/app/graphql/types/queries_permalink.rb +++ b/app/graphql/types/queries_permalink.rb @@ -14,7 +14,7 @@ module QueriesPermalink Retrieve a single `Permalink` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_schemas.rb b/app/graphql/types/queries_schemas.rb index aad51199..28246653 100644 --- a/app/graphql/types/queries_schemas.rb +++ b/app/graphql/types/queries_schemas.rb @@ -19,7 +19,7 @@ module QueriesSchemas field :schema_definition, Types::SchemaDefinitionType, null: true do description "Look up a schema definition by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :schema_definitions, resolver: Resolvers::SchemaDefinitionResolver do @@ -29,7 +29,7 @@ module QueriesSchemas field :schema_version, Types::SchemaVersionType, null: true do description "Look up a schema version by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :schema_versions, resolver: Resolvers::SchemaVersionResolver do diff --git a/app/graphql/types/queries_submission.rb b/app/graphql/types/queries_submission.rb index ad2b5496..d7925e82 100644 --- a/app/graphql/types/queries_submission.rb +++ b/app/graphql/types/queries_submission.rb @@ -14,7 +14,7 @@ module QueriesSubmission Retrieve a single `Submission` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_submission_comment.rb b/app/graphql/types/queries_submission_comment.rb index 4a0637cf..80c992ed 100644 --- a/app/graphql/types/queries_submission_comment.rb +++ b/app/graphql/types/queries_submission_comment.rb @@ -14,7 +14,7 @@ module QueriesSubmissionComment Retrieve a single `SubmissionComment` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_submission_review.rb b/app/graphql/types/queries_submission_review.rb index ae427797..aa25a41b 100644 --- a/app/graphql/types/queries_submission_review.rb +++ b/app/graphql/types/queries_submission_review.rb @@ -14,7 +14,7 @@ module QueriesSubmissionReview Retrieve a single `SubmissionReview` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_submission_target.rb b/app/graphql/types/queries_submission_target.rb index e836f5fe..54224d29 100644 --- a/app/graphql/types/queries_submission_target.rb +++ b/app/graphql/types/queries_submission_target.rb @@ -14,7 +14,7 @@ module QueriesSubmissionTarget Retrieve a single `SubmissionTarget` by slug. TEXT - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/app/graphql/types/queries_submission_target_reviewer.rb b/app/graphql/types/queries_submission_target_reviewer.rb new file mode 100644 index 00000000..fef2f01e --- /dev/null +++ b/app/graphql/types/queries_submission_target_reviewer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + # An interface for querying {SubmissionTargetReviewer}. + module QueriesSubmissionTargetReviewer + include Types::BaseInterface + + description <<~TEXT + An interface for querying `SubmissionTargetReviewer` records. + TEXT + + field :submission_target_reviewer, ::Types::SubmissionTargetReviewerType, null: true do + description <<~TEXT + Retrieve a single `SubmissionTargetReviewer` by slug. + TEXT + + argument :slug, Support::GQL::SlugType, required: true do + description <<~TEXT + The slug to look up. + TEXT + end + end + + field :submission_target_reviewers, resolver: ::Resolvers::SubmissionTargetReviewerResolver + + # @param [String] slug + # @return [SubmissionTargetReviewer, nil] + def submission_target_reviewer(slug:) + load_record_with(SubmissionTargetReviewer, slug) + end + end +end diff --git a/app/graphql/types/queries_user.rb b/app/graphql/types/queries_user.rb index 540dba56..6c7fa259 100644 --- a/app/graphql/types/queries_user.rb +++ b/app/graphql/types/queries_user.rb @@ -8,7 +8,7 @@ module QueriesUser field :user, Types::UserType, null: true do description "Look up a user by slug" - argument :slug, Types::SlugType, required: true + argument :slug, Support::GQL::SlugType, required: true end field :users, resolver: Resolvers::UserResolver do diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 41651029..367320e8 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -29,6 +29,7 @@ class QueryType < Types::BaseObject implements ::Types::QueriesSubmission implements ::Types::QueriesSubmissionReview implements ::Types::QueriesSubmissionTarget + implements ::Types::QueriesSubmissionTargetReviewer implements ::Types::QueriesUser implements ::Types::SearchableType diff --git a/app/graphql/types/role_permission_grid_type.rb b/app/graphql/types/role_permission_grid_type.rb new file mode 100644 index 00000000..389cf047 --- /dev/null +++ b/app/graphql/types/role_permission_grid_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + # @see Roles::RolePermissionGrid + class RolePermissionGridType < Types::BaseObject + implements Types::PermissionGridType + implements Types::CRUDPermissionGridType + + description <<~TEXT + A grid of permissions related to role management in Meru. + + It lives in the `GlobalAccessControlList`. + + This does not determine anything about role _assignment_, + for that, see the `manageAccess` permission in entity grids. + Instead, the permissions here are solely related to managing + role records themselves. + TEXT + end +end diff --git a/app/graphql/types/role_system_identifier_type.rb b/app/graphql/types/role_system_identifier_type.rb index e9c27b4e..4479d9c4 100644 --- a/app/graphql/types/role_system_identifier_type.rb +++ b/app/graphql/types/role_system_identifier_type.rb @@ -28,6 +28,24 @@ class RoleSystemIdentifierType < Types::BaseEnum TEXT end + value "REVIEWER", value: "reviewer" do + description <<~TEXT + A reviewer can review any assigned entity as well as its subcollections and items. + TEXT + end + + value "DEPOSITOR", value: "depositor" do + description <<~TEXT + A depositor can read anything under its assigned hierarchy, and can also deposit new items and collections. + TEXT + end + + value "AUTHOR", value: "author" do + description <<~TEXT + An author can update and read its own entity, but does not have permissions to do anything else. + TEXT + end + value "READER", value: "reader" do description <<~TEXT A reader is anyone who has been given explicit read-access to an entity. diff --git a/app/graphql/types/role_type.rb b/app/graphql/types/role_type.rb index 6fb43f54..864d6c6e 100644 --- a/app/graphql/types/role_type.rb +++ b/app/graphql/types/role_type.rb @@ -2,7 +2,7 @@ module Types # @see Role - class RoleType < Types::AbstractModel + class RoleType < Types::BaseModel description "A named role in the Meru API" field :identifier, Types::RoleSystemIdentifierType, null: true do diff --git a/app/graphql/types/schema_definition_type.rb b/app/graphql/types/schema_definition_type.rb index c37a7bd5..0edeb42b 100644 --- a/app/graphql/types/schema_definition_type.rb +++ b/app/graphql/types/schema_definition_type.rb @@ -2,7 +2,7 @@ module Types # @see SchemaDefinition - class SchemaDefinitionType < Types::AbstractModel + class SchemaDefinitionType < Types::BaseModel disable_auth_checks! description <<~TEXT diff --git a/app/graphql/types/schema_version_type.rb b/app/graphql/types/schema_version_type.rb index 274635b7..ac96d4e0 100644 --- a/app/graphql/types/schema_version_type.rb +++ b/app/graphql/types/schema_version_type.rb @@ -2,7 +2,7 @@ module Types # @see SchemaVersion - class SchemaVersionType < Types::AbstractModel + class SchemaVersionType < Types::BaseModel disable_auth_checks! description <<~TEXT @@ -37,7 +37,7 @@ class SchemaVersionType < Types::AbstractModel TEXT end - field :enforced_parent_declarations, [Types::SlugType, { null: false }], null: false do + field :enforced_parent_declarations, [Support::GQL::SlugType, { null: false }], null: false do description <<~TEXT Declarations / slugs for `enforcedParentVersions`. TEXT @@ -63,7 +63,7 @@ class SchemaVersionType < Types::AbstractModel TEXT end - field :enforced_child_declarations, [Types::SlugType, { null: false }], null: false do + field :enforced_child_declarations, [Support::GQL::SlugType, { null: false }], null: false do description <<~TEXT Declarations / slugs for `enforcedChildVersions`. TEXT diff --git a/app/graphql/types/search_result_type.rb b/app/graphql/types/search_result_type.rb index eda4e600..ade04710 100644 --- a/app/graphql/types/search_result_type.rb +++ b/app/graphql/types/search_result_type.rb @@ -4,7 +4,7 @@ module Types # @see Resolvers::SearchResultResolver class SearchResultType < Types::BaseObject implements ::GraphQL::Types::Relay::Node - implements ::Types::SluggableType + implements ::Support::GQL::SluggableType description <<~TEXT An entity that's the result of a search. @@ -34,7 +34,7 @@ class SearchResultType < Types::BaseObject TEXT end - field :slug, Types::SlugType, null: false do + field :slug, Support::GQL::SlugType, null: false do description <<~TEXT The slug for the entity. TEXT diff --git a/app/graphql/types/settings/contributors_settings_input_type.rb b/app/graphql/types/settings/contributors_settings_input_type.rb new file mode 100644 index 00000000..d252fda6 --- /dev/null +++ b/app/graphql/types/settings/contributors_settings_input_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module Settings + # @see Settings::Contributors + # @see Types::Settings::ContributorsSettingsType + class ContributorsSettingsInputType < Types::HashInputObject + description <<~TEXT + Settings related to how contributors are handled in this installation. + TEXT + + argument :claimable, Boolean, required: false, default_value: MeruConfig.contributor_claimable, replace_null_with_default: true do + description <<~TEXT + Whether users can claim ownership of an unclaimed contributor in this installation. + TEXT + end + + argument :owner_updatable, Boolean, required: false, default_value: MeruConfig.contributor_owner_updatable, replace_null_with_default: true do + description <<~TEXT + Whether users who have claimed a contributor can manage that contributor + without needing to be an admin or anything else. + TEXT + end + end + end +end diff --git a/app/graphql/types/settings/contributors_settings_type.rb b/app/graphql/types/settings/contributors_settings_type.rb new file mode 100644 index 00000000..fd8768f6 --- /dev/null +++ b/app/graphql/types/settings/contributors_settings_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module Settings + # @see Settings::Contributors + # @see Types::Settings::ContributorsSettingsInputType + class ContributorsSettingsType < Types::BaseObject + description <<~TEXT + Settings related to how contributors are handled in this installation. + TEXT + + field :claimable, Boolean, null: false do + description <<~TEXT + Whether users can claim ownership of an unclaimed contributor in this installation. + TEXT + end + + field :owner_updatable, Boolean, null: false do + description <<~TEXT + Whether users who have claimed a contributor can manage that contributor + without needing to be an admin or anything else. + TEXT + end + end + end +end diff --git a/app/graphql/types/settings_permission_grid_type.rb b/app/graphql/types/settings_permission_grid_type.rb new file mode 100644 index 00000000..33bf7836 --- /dev/null +++ b/app/graphql/types/settings_permission_grid_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + class SettingsPermissionGridType < Types::BaseObject + description <<~TEXT + Permissions related to managing global configuration settings in Meru. + TEXT + + implements Types::PermissionGridType + + field :update, Boolean, null: false do + description <<~TEXT + Whether the user can update global configuration settings in Meru. + TEXT + end + end +end diff --git a/app/graphql/types/simple_order_type.rb b/app/graphql/types/simple_order_type.rb deleted file mode 100644 index ed5a0da7..00000000 --- a/app/graphql/types/simple_order_type.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Types - class SimpleOrderType < Types::BaseEnum - description "A generic enum for sorting models that don't have anything more specific implemented" - - value "RECENT", description: "Sort models by newest created date" - value "OLDEST", description: "Sort models by oldest created date" - end -end diff --git a/app/graphql/types/slug_type.rb b/app/graphql/types/slug_type.rb deleted file mode 100644 index 53cba8ba..00000000 --- a/app/graphql/types/slug_type.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Types - class SlugType < Types::BaseScalar - description "A slug that can identify a record in context" - - SCHEMA_SLUG_PATTERN = / - \A(?:[a-z_]+):(?:[a-z_]+)(?::[^:]+)?\z - /x - - class << self - def coerce_input(input_value, context) - case input_value - when SCHEMA_SLUG_PATTERN then input_value - when AnonymousUser::ID then AnonymousUser::ID - else - Support::System["slugs.decode_id"].call(input_value).value_or(nil) - end - end - - def coerce_result(ruby_value, context) - case ruby_value - when SCHEMA_SLUG_PATTERN then ruby_value - when AnonymousUser::ID then AnonymousUser::ID - else - Support::System["slugs.encode_id"].call(ruby_value).value_or(nil) - end - end - end - end -end diff --git a/app/graphql/types/sluggable_type.rb b/app/graphql/types/sluggable_type.rb deleted file mode 100644 index dc287027..00000000 --- a/app/graphql/types/sluggable_type.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Types - module SluggableType - include BaseInterface - - description "Objects have a serialized slug for looking them up in the system and generating links without UUIDs" - - field :slug, Types::SlugType, null: false - - def slug - object.id - end - end -end diff --git a/app/graphql/types/submission_batch_publication_transition_type.rb b/app/graphql/types/submission_batch_publication_transition_type.rb index 059950c9..9380a4af 100644 --- a/app/graphql/types/submission_batch_publication_transition_type.rb +++ b/app/graphql/types/submission_batch_publication_transition_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionBatchPublicationTransition # @see ::Types::SubmissionBatchPublicationTransitionConnectionType # @see ::Types::SubmissionBatchPublicationTransitionEdgeType - class SubmissionBatchPublicationTransitionType < Types::AbstractModel + class SubmissionBatchPublicationTransitionType < Types::BaseModel description <<~TEXT A transition for a `SubmissionBatchPublication`. TEXT diff --git a/app/graphql/types/submission_batch_publication_type.rb b/app/graphql/types/submission_batch_publication_type.rb index 6117592a..d0a8db93 100644 --- a/app/graphql/types/submission_batch_publication_type.rb +++ b/app/graphql/types/submission_batch_publication_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionBatchPublication # @see ::Types::SubmissionBatchPublicationConnectionType # @see ::Types::SubmissionBatchPublicationEdgeType - class SubmissionBatchPublicationType < Types::AbstractModel + class SubmissionBatchPublicationType < Types::BaseModel description <<~TEXT The record of a batch publication of one or more submissions within a submission target. TEXT diff --git a/app/graphql/types/submission_comment_type.rb b/app/graphql/types/submission_comment_type.rb index 7639ec2e..6e8b8295 100644 --- a/app/graphql/types/submission_comment_type.rb +++ b/app/graphql/types/submission_comment_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionComment # @see ::Types::SubmissionCommentConnectionType # @see ::Types::SubmissionCommentEdgeType - class SubmissionCommentType < Types::AbstractModel + class SubmissionCommentType < Types::BaseModel description <<~TEXT A comment on a `Submission`. TEXT diff --git a/app/graphql/types/submission_deposit_target_type.rb b/app/graphql/types/submission_deposit_target_type.rb index 9e26fe8c..7e090ba6 100644 --- a/app/graphql/types/submission_deposit_target_type.rb +++ b/app/graphql/types/submission_deposit_target_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionDepositTarget # @see ::Types::SubmissionDepositTargetConnectionType # @see ::Types::SubmissionDepositTargetEdgeType - class SubmissionDepositTargetType < Types::AbstractModel + class SubmissionDepositTargetType < Types::BaseModel description <<~TEXT A submission deposit target defines an actual target entity for submissions. For instance, a `SubmissionTarget` diff --git a/app/graphql/types/submission_publication_transition_type.rb b/app/graphql/types/submission_publication_transition_type.rb index 75aaed2f..78431f70 100644 --- a/app/graphql/types/submission_publication_transition_type.rb +++ b/app/graphql/types/submission_publication_transition_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionPublicationTransition # @see ::Types::SubmissionPublicationTransitionConnectionType # @see ::Types::SubmissionPublicationTransitionEdgeType - class SubmissionPublicationTransitionType < Types::AbstractModel + class SubmissionPublicationTransitionType < Types::BaseModel description <<~TEXT A transition for a `SubmissionPublication`. TEXT diff --git a/app/graphql/types/submission_publication_type.rb b/app/graphql/types/submission_publication_type.rb index c285c1c7..9f714b9e 100644 --- a/app/graphql/types/submission_publication_type.rb +++ b/app/graphql/types/submission_publication_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionPublication # @see ::Types::SubmissionPublicationConnectionType # @see ::Types::SubmissionPublicationEdgeType - class SubmissionPublicationType < Types::AbstractModel + class SubmissionPublicationType < Types::BaseModel description <<~TEXT The record of a `Submission`'s publication process. TEXT diff --git a/app/graphql/types/submission_review_transition_type.rb b/app/graphql/types/submission_review_transition_type.rb index cbad1eee..ea90ca08 100644 --- a/app/graphql/types/submission_review_transition_type.rb +++ b/app/graphql/types/submission_review_transition_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionReviewTransition # @see ::Types::SubmissionReviewTransitionConnectionType # @see ::Types::SubmissionReviewTransitionEdgeType - class SubmissionReviewTransitionType < Types::AbstractModel + class SubmissionReviewTransitionType < Types::BaseModel description <<~TEXT A transition for a `SubmissionReview`. TEXT diff --git a/app/graphql/types/submission_review_type.rb b/app/graphql/types/submission_review_type.rb index 09256820..b12c521a 100644 --- a/app/graphql/types/submission_review_type.rb +++ b/app/graphql/types/submission_review_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionReview # @see ::Types::SubmissionReviewConnectionType # @see ::Types::SubmissionReviewEdgeType - class SubmissionReviewType < Types::AbstractModel + class SubmissionReviewType < Types::BaseModel description <<~TEXT A review of a `Submission` by a specific reviewer. TEXT diff --git a/app/graphql/types/submission_target_reviewer_order_type.rb b/app/graphql/types/submission_target_reviewer_order_type.rb new file mode 100644 index 00000000..2d8252a9 --- /dev/null +++ b/app/graphql/types/submission_target_reviewer_order_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + # @see Resolvers::OrderedAsSubmissionTargetReviewer + class SubmissionTargetReviewerOrderType < Types::BaseEnum + description <<~TEXT + Sort a collection of `SubmissionTargetReviewer` records by specific properties and directions. + TEXT + + value "DEFAULT" do + description "Sort submission target reviewers by their default order." + end + + value "RECENT" do + description "Sort submission target reviewers by newest created date." + end + + value "OLDEST" do + description "Sort submission target reviewers by oldest created date." + end + end +end diff --git a/app/graphql/types/submission_target_reviewer_type.rb b/app/graphql/types/submission_target_reviewer_type.rb index 35c7de26..0223bb55 100644 --- a/app/graphql/types/submission_target_reviewer_type.rb +++ b/app/graphql/types/submission_target_reviewer_type.rb @@ -2,7 +2,7 @@ module Types # @see SubmissionTargetReviewer - class SubmissionTargetReviewerType < Types::AbstractModel + class SubmissionTargetReviewerType < Types::BaseModel description <<~TEXT A reviewer assigned to a `SubmissionTarget`. TEXT diff --git a/app/graphql/types/submission_target_transition_type.rb b/app/graphql/types/submission_target_transition_type.rb index e022455e..0b36a18f 100644 --- a/app/graphql/types/submission_target_transition_type.rb +++ b/app/graphql/types/submission_target_transition_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionTargetTransition # @see ::Types::SubmissionTargetTransitionConnectionType # @see ::Types::SubmissionTargetTransitionEdgeType - class SubmissionTargetTransitionType < Types::AbstractModel + class SubmissionTargetTransitionType < Types::BaseModel description <<~TEXT A transition for a `SubmissionTarget`. TEXT diff --git a/app/graphql/types/submission_target_type.rb b/app/graphql/types/submission_target_type.rb index f9eef313..525a3a0e 100644 --- a/app/graphql/types/submission_target_type.rb +++ b/app/graphql/types/submission_target_type.rb @@ -4,7 +4,7 @@ module Types # @see SubmissionTarget # @see ::Types::SubmissionTargetConnectionType # @see ::Types::SubmissionTargetEdgeType - class SubmissionTargetType < Types::AbstractModel + class SubmissionTargetType < Types::BaseModel description <<~TEXT A submission target is a subject of an `Entity`, specifying information about whether or not it can have new entities deposited to it. @@ -54,6 +54,13 @@ class SubmissionTargetType < Types::AbstractModel TEXT end + field :agreement_content_with_fallback, String, null: true do + description <<~TEXT + The content of the agreement that must be agreed to before depositing to this submission target, + falling back to the global agreement if this submission target doesn't have its own agreement content. + TEXT + end + field :agreement_required, Boolean, null: false do description <<~TEXT Whether or not this submission target requires agreement to an agreement before depositing. @@ -78,6 +85,12 @@ class SubmissionTargetType < Types::AbstractModel TEXT end + field :reviewers, resolver: ::Resolvers::SubmissionTargetReviewerResolver, null: false do + description <<~TEXT + The reviewers that are assigned to review this submission target. + TEXT + end + field :transitions, resolver: ::Resolvers::SubmissionTargetTransitionResolver, null: false do description <<~TEXT The state transitions that this submission target has undergone. diff --git a/app/graphql/types/submission_transition_type.rb b/app/graphql/types/submission_transition_type.rb index 4eb56f78..6c569752 100644 --- a/app/graphql/types/submission_transition_type.rb +++ b/app/graphql/types/submission_transition_type.rb @@ -5,7 +5,7 @@ module Types # @see SubmissionTransitionPolicy # @see ::Types::SubmissionTransitionConnectionType # @see ::Types::SubmissionTransitionEdgeType - class SubmissionTransitionType < Types::AbstractModel + class SubmissionTransitionType < Types::BaseModel description <<~TEXT A transition for a `Submission`. TEXT diff --git a/app/graphql/types/submission_type.rb b/app/graphql/types/submission_type.rb index 5024b107..26904dae 100644 --- a/app/graphql/types/submission_type.rb +++ b/app/graphql/types/submission_type.rb @@ -4,7 +4,7 @@ module Types # @see Submission # @see ::Types::SubmissionConnectionType # @see ::Types::SubmissionEdgeType - class SubmissionType < Types::AbstractModel + class SubmissionType < Types::BaseModel description <<~TEXT A submission against a `SubmissionTarget`, representing a single attempt to deposit an entity into the system. diff --git a/app/graphql/types/system_info_type.rb b/app/graphql/types/system_info_type.rb index d0188924..835966b5 100644 --- a/app/graphql/types/system_info_type.rb +++ b/app/graphql/types/system_info_type.rb @@ -12,11 +12,11 @@ class SystemInfoType < Types::BaseObject Check to see if an entity of a given `descendant` type exists with a given `ancestor` type. TEXT - argument :ancestor, Types::SlugType, required: true do + argument :ancestor, Support::GQL::SlugType, required: true do description "Should be `namespace:identifier`." end - argument :descendant, Types::SlugType, required: true do + argument :descendant, Support::GQL::SlugType, required: true do description "Should be `namespace:identifier`." end end diff --git a/app/graphql/types/template_instance_sibling_type.rb b/app/graphql/types/template_instance_sibling_type.rb index bd361f7d..c3ec22bb 100644 --- a/app/graphql/types/template_instance_sibling_type.rb +++ b/app/graphql/types/template_instance_sibling_type.rb @@ -2,7 +2,7 @@ module Types # @see TemplateInstanceSibling - class TemplateInstanceSiblingType < Types::AbstractModel + class TemplateInstanceSiblingType < Types::BaseModel description <<~TEXT A brief detail about a template instance's siblings, to help with finessing certain style concerns. diff --git a/app/graphql/types/templates/blurb_template_definition_type.rb b/app/graphql/types/templates/blurb_template_definition_type.rb index a73c4ca0..82acf0b6 100644 --- a/app/graphql/types/templates/blurb_template_definition_type.rb +++ b/app/graphql/types/templates/blurb_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::BlurbDefinition - class BlurbTemplateDefinitionType < AbstractModel + class BlurbTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::BlurbTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/blurb_template_instance_type.rb b/app/graphql/types/templates/blurb_template_instance_type.rb index 79e95ba5..32eb859f 100644 --- a/app/graphql/types/templates/blurb_template_instance_type.rb +++ b/app/graphql/types/templates/blurb_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::BlurbInstance - class BlurbTemplateInstanceType < AbstractModel + class BlurbTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType field :definition, ::Types::Templates::BlurbTemplateDefinitionType, null: false do diff --git a/app/graphql/types/templates/contributor_list_template_definition_type.rb b/app/graphql/types/templates/contributor_list_template_definition_type.rb index d8c0c615..c5c56089 100644 --- a/app/graphql/types/templates/contributor_list_template_definition_type.rb +++ b/app/graphql/types/templates/contributor_list_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::ContributorListDefinition - class ContributorListTemplateDefinitionType < AbstractModel + class ContributorListTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::ContributorListTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/contributor_list_template_instance_type.rb b/app/graphql/types/templates/contributor_list_template_instance_type.rb index 165fbd26..f54ea0b3 100644 --- a/app/graphql/types/templates/contributor_list_template_instance_type.rb +++ b/app/graphql/types/templates/contributor_list_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::ContributorListInstance - class ContributorListTemplateInstanceType < AbstractModel + class ContributorListTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType implements ::Types::TemplateHasContributionListType diff --git a/app/graphql/types/templates/descendant_list_template_definition_type.rb b/app/graphql/types/templates/descendant_list_template_definition_type.rb index c09288b0..728ec209 100644 --- a/app/graphql/types/templates/descendant_list_template_definition_type.rb +++ b/app/graphql/types/templates/descendant_list_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::DescendantListDefinition - class DescendantListTemplateDefinitionType < AbstractModel + class DescendantListTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::DescendantListTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/descendant_list_template_instance_type.rb b/app/graphql/types/templates/descendant_list_template_instance_type.rb index 7bf20435..06a6b982 100644 --- a/app/graphql/types/templates/descendant_list_template_instance_type.rb +++ b/app/graphql/types/templates/descendant_list_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::DescendantListInstance - class DescendantListTemplateInstanceType < AbstractModel + class DescendantListTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType implements ::Types::TemplateHasEntityListType implements ::Types::TemplateHasSeeAllOrderingType diff --git a/app/graphql/types/templates/detail_template_definition_type.rb b/app/graphql/types/templates/detail_template_definition_type.rb index 45e41eb0..d6db6352 100644 --- a/app/graphql/types/templates/detail_template_definition_type.rb +++ b/app/graphql/types/templates/detail_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::DetailDefinition - class DetailTemplateDefinitionType < AbstractModel + class DetailTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::DetailTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/detail_template_instance_type.rb b/app/graphql/types/templates/detail_template_instance_type.rb index ab61efc6..cdabbbe8 100644 --- a/app/graphql/types/templates/detail_template_instance_type.rb +++ b/app/graphql/types/templates/detail_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::DetailInstance - class DetailTemplateInstanceType < AbstractModel + class DetailTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType field :definition, ::Types::Templates::DetailTemplateDefinitionType, null: false do diff --git a/app/graphql/types/templates/hero_template_definition_type.rb b/app/graphql/types/templates/hero_template_definition_type.rb index e9dd9430..d4579093 100644 --- a/app/graphql/types/templates/hero_template_definition_type.rb +++ b/app/graphql/types/templates/hero_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::HeroDefinition - class HeroTemplateDefinitionType < AbstractModel + class HeroTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::HeroTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/hero_template_instance_type.rb b/app/graphql/types/templates/hero_template_instance_type.rb index 9b6dc44f..3aa7a805 100644 --- a/app/graphql/types/templates/hero_template_instance_type.rb +++ b/app/graphql/types/templates/hero_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::HeroInstance - class HeroTemplateInstanceType < AbstractModel + class HeroTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType field :definition, ::Types::Templates::HeroTemplateDefinitionType, null: false do diff --git a/app/graphql/types/templates/link_list_template_definition_type.rb b/app/graphql/types/templates/link_list_template_definition_type.rb index 6ffcc904..19479819 100644 --- a/app/graphql/types/templates/link_list_template_definition_type.rb +++ b/app/graphql/types/templates/link_list_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::LinkListDefinition - class LinkListTemplateDefinitionType < AbstractModel + class LinkListTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::LinkListTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/link_list_template_instance_type.rb b/app/graphql/types/templates/link_list_template_instance_type.rb index d3da5f17..09ee2e12 100644 --- a/app/graphql/types/templates/link_list_template_instance_type.rb +++ b/app/graphql/types/templates/link_list_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::LinkListInstance - class LinkListTemplateInstanceType < AbstractModel + class LinkListTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType implements ::Types::TemplateHasEntityListType implements ::Types::TemplateHasSeeAllOrderingType diff --git a/app/graphql/types/templates/list_item_template_definition_type.rb b/app/graphql/types/templates/list_item_template_definition_type.rb index 0cf1bbbc..c50f3826 100644 --- a/app/graphql/types/templates/list_item_template_definition_type.rb +++ b/app/graphql/types/templates/list_item_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::ListItemDefinition - class ListItemTemplateDefinitionType < AbstractModel + class ListItemTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::ListItemTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/list_item_template_instance_type.rb b/app/graphql/types/templates/list_item_template_instance_type.rb index 383b4d97..dcfb8887 100644 --- a/app/graphql/types/templates/list_item_template_instance_type.rb +++ b/app/graphql/types/templates/list_item_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::ListItemInstance - class ListItemTemplateInstanceType < AbstractModel + class ListItemTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType implements ::Types::TemplateHasEntityListType implements ::Types::TemplateHasSeeAllOrderingType diff --git a/app/graphql/types/templates/metadata_template_definition_type.rb b/app/graphql/types/templates/metadata_template_definition_type.rb index 0b823cd2..68fc6fb4 100644 --- a/app/graphql/types/templates/metadata_template_definition_type.rb +++ b/app/graphql/types/templates/metadata_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::MetadataDefinition - class MetadataTemplateDefinitionType < AbstractModel + class MetadataTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::MetadataTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/metadata_template_instance_type.rb b/app/graphql/types/templates/metadata_template_instance_type.rb index 7c2bf643..a75498e0 100644 --- a/app/graphql/types/templates/metadata_template_instance_type.rb +++ b/app/graphql/types/templates/metadata_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::MetadataInstance - class MetadataTemplateInstanceType < AbstractModel + class MetadataTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType field :definition, ::Types::Templates::MetadataTemplateDefinitionType, null: false do diff --git a/app/graphql/types/templates/navigation_template_definition_type.rb b/app/graphql/types/templates/navigation_template_definition_type.rb index 8e769348..1ac3075d 100644 --- a/app/graphql/types/templates/navigation_template_definition_type.rb +++ b/app/graphql/types/templates/navigation_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::NavigationDefinition - class NavigationTemplateDefinitionType < AbstractModel + class NavigationTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::NavigationTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/navigation_template_instance_type.rb b/app/graphql/types/templates/navigation_template_instance_type.rb index 7f4128fe..660186cc 100644 --- a/app/graphql/types/templates/navigation_template_instance_type.rb +++ b/app/graphql/types/templates/navigation_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::NavigationInstance - class NavigationTemplateInstanceType < AbstractModel + class NavigationTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType field :definition, ::Types::Templates::NavigationTemplateDefinitionType, null: false do diff --git a/app/graphql/types/templates/ordering_template_definition_type.rb b/app/graphql/types/templates/ordering_template_definition_type.rb index 9796785b..22ea31c0 100644 --- a/app/graphql/types/templates/ordering_template_definition_type.rb +++ b/app/graphql/types/templates/ordering_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::OrderingDefinition - class OrderingTemplateDefinitionType < AbstractModel + class OrderingTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::OrderingTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/ordering_template_instance_type.rb b/app/graphql/types/templates/ordering_template_instance_type.rb index 730e0a86..b8cb396c 100644 --- a/app/graphql/types/templates/ordering_template_instance_type.rb +++ b/app/graphql/types/templates/ordering_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::OrderingInstance - class OrderingTemplateInstanceType < AbstractModel + class OrderingTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType implements ::Types::TemplateHasOrderingPairType diff --git a/app/graphql/types/templates/page_list_template_definition_type.rb b/app/graphql/types/templates/page_list_template_definition_type.rb index a827f340..8e1a5373 100644 --- a/app/graphql/types/templates/page_list_template_definition_type.rb +++ b/app/graphql/types/templates/page_list_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::PageListDefinition - class PageListTemplateDefinitionType < AbstractModel + class PageListTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::PageListTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/page_list_template_instance_type.rb b/app/graphql/types/templates/page_list_template_instance_type.rb index a6c42b14..7f37b4f3 100644 --- a/app/graphql/types/templates/page_list_template_instance_type.rb +++ b/app/graphql/types/templates/page_list_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::PageListInstance - class PageListTemplateInstanceType < AbstractModel + class PageListTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType field :definition, ::Types::Templates::PageListTemplateDefinitionType, null: false do diff --git a/app/graphql/types/templates/supplementary_template_definition_type.rb b/app/graphql/types/templates/supplementary_template_definition_type.rb index 6bf9edfc..a8412da7 100644 --- a/app/graphql/types/templates/supplementary_template_definition_type.rb +++ b/app/graphql/types/templates/supplementary_template_definition_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::SupplementaryDefinition - class SupplementaryTemplateDefinitionType < AbstractModel + class SupplementaryTemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::SupplementaryTemplateDefinitionSlotsType, null: false do diff --git a/app/graphql/types/templates/supplementary_template_instance_type.rb b/app/graphql/types/templates/supplementary_template_instance_type.rb index bb4b2935..371a7c18 100644 --- a/app/graphql/types/templates/supplementary_template_instance_type.rb +++ b/app/graphql/types/templates/supplementary_template_instance_type.rb @@ -3,7 +3,7 @@ module Types module Templates # @see ::Templates::SupplementaryInstance - class SupplementaryTemplateInstanceType < AbstractModel + class SupplementaryTemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType field :definition, ::Types::Templates::SupplementaryTemplateDefinitionType, null: false do diff --git a/app/graphql/types/user_collection_access_grant_type.rb b/app/graphql/types/user_collection_access_grant_type.rb index d3bc5c87..21f657b1 100644 --- a/app/graphql/types/user_collection_access_grant_type.rb +++ b/app/graphql/types/user_collection_access_grant_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class UserCollectionAccessGrantType < Types::AbstractModel + class UserCollectionAccessGrantType < Types::BaseModel implements Types::AccessGrantType implements Types::UserAccessGrantType diff --git a/app/graphql/types/user_community_access_grant_type.rb b/app/graphql/types/user_community_access_grant_type.rb index 6cad2d1c..9337c5b9 100644 --- a/app/graphql/types/user_community_access_grant_type.rb +++ b/app/graphql/types/user_community_access_grant_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class UserCommunityAccessGrantType < Types::AbstractModel + class UserCommunityAccessGrantType < Types::BaseModel implements Types::AccessGrantType implements Types::UserAccessGrantType diff --git a/app/graphql/types/user_group_collection_access_grant_type.rb b/app/graphql/types/user_group_collection_access_grant_type.rb index 50441d82..070a418c 100644 --- a/app/graphql/types/user_group_collection_access_grant_type.rb +++ b/app/graphql/types/user_group_collection_access_grant_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class UserGroupCollectionAccessGrantType < Types::AbstractModel + class UserGroupCollectionAccessGrantType < Types::BaseModel implements Types::AccessGrantType implements Types::UserGroupAccessGrantType diff --git a/app/graphql/types/user_group_community_access_grant_type.rb b/app/graphql/types/user_group_community_access_grant_type.rb index 4fea7a61..4ace3e43 100644 --- a/app/graphql/types/user_group_community_access_grant_type.rb +++ b/app/graphql/types/user_group_community_access_grant_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class UserGroupCommunityAccessGrantType < Types::AbstractModel + class UserGroupCommunityAccessGrantType < Types::BaseModel implements Types::AccessGrantType implements Types::UserGroupAccessGrantType diff --git a/app/graphql/types/user_group_item_access_grant_type.rb b/app/graphql/types/user_group_item_access_grant_type.rb index 340a5499..34c5be04 100644 --- a/app/graphql/types/user_group_item_access_grant_type.rb +++ b/app/graphql/types/user_group_item_access_grant_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class UserGroupItemAccessGrantType < Types::AbstractModel + class UserGroupItemAccessGrantType < Types::BaseModel implements Types::AccessGrantType implements Types::UserGroupAccessGrantType diff --git a/app/graphql/types/user_group_type.rb b/app/graphql/types/user_group_type.rb index a8951d83..c6cda85c 100644 --- a/app/graphql/types/user_group_type.rb +++ b/app/graphql/types/user_group_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class UserGroupType < Types::AbstractModel + class UserGroupType < Types::BaseModel implements Types::AccessGrantSubjectType description <<~TEXT diff --git a/app/graphql/types/user_item_access_grant_type.rb b/app/graphql/types/user_item_access_grant_type.rb index df11bcb2..b64bd92e 100644 --- a/app/graphql/types/user_item_access_grant_type.rb +++ b/app/graphql/types/user_item_access_grant_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Types - class UserItemAccessGrantType < Types::AbstractModel + class UserItemAccessGrantType < Types::BaseModel implements Types::AccessGrantType implements Types::UserAccessGrantType diff --git a/app/graphql/types/user_permission_grid_type.rb b/app/graphql/types/user_permission_grid_type.rb new file mode 100644 index 00000000..b8155e48 --- /dev/null +++ b/app/graphql/types/user_permission_grid_type.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class UserPermissionGridType < Types::BaseObject + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 48f68213..2301fdc7 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -3,7 +3,7 @@ module Types # @see AnonymousUser # @see User - class UserType < Types::AbstractModel + class UserType < Types::BaseModel implements Types::AccessGrantSubjectType implements Types::ExposesPermissionsType @@ -97,6 +97,20 @@ class UserType < Types::AbstractModel TEXT end + expose_authorization_rule :access_admin?, <<~TEXT + Whether this user has access to the admin section of Meru. + + Only a `viewer` with admin access will be able to see the actual + result of this auth check. + TEXT + + expose_authorization_rule :claim_contributor?, <<~TEXT + Whether this user has the ability to claim an unclaimed contributor profile as their own. + + This requires both that the user has permission to manage the system broadly, + and that they do not already have a contributor profile linked to their account. + TEXT + expose_authorization_rule :receive_review_requests?, <<~TEXT Whether this user is a reviewer on **any** submission targets, and should see information about potential review requests in the UI. diff --git a/app/jobs/contributors/merge_job.rb b/app/jobs/contributors/merge_job.rb new file mode 100644 index 00000000..c3637453 --- /dev/null +++ b/app/jobs/contributors/merge_job.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Contributors + # A background job that will merge two {Contributor}s into each other. + class MergeJob < ApplicationJob + include ActiveJob::Continuable + + queue_as :default + + discard_on Contributors::MergeInvalid + + # @return [Contributor] + attr_reader :source + + # @return [Contributor] + attr_reader :target + + # @param [Contributor] source + # @param [Contributor] target + # @return [void] + def perform(source, target) + verify_merge_lock!(source, target) + + step :copy_contributions + + step :redirect_harvesting + + step :mark_merge_complete + + step :destroy_source + end + + private + + # @param [Contributor] source + # @param [Contributor] target + # @return [void] + def verify_merge_lock!(source, target) + merge_lock = source.merge_to(target) + + merge_lock.or do + raise Contributors::MergeInvalid, "Failed to acquire merge lock for #{source.id} -> #{target.id}" + end.value! + + @source = source + @target = target + end + + # @return [void] + def check_result!(result) + result.or do + raise Contributors::MergeFailed, "Merge failed: #{result.failure.inspect}" + end.value! + end + + # @return [void] + def copy_contributions + check_result!(source.copy_contributions) + end + + def redirect_harvesting + source.redirect_harvesting_to!(target) + end + + def mark_merge_complete + source.update!(merge_source_status: :merged) + end + + def destroy_source + source.destroy! + end + end +end diff --git a/app/models/application_frozen_record.rb b/app/models/application_frozen_record.rb new file mode 100644 index 00000000..53a1b64e --- /dev/null +++ b/app/models/application_frozen_record.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# @abstract +class ApplicationFrozenRecord < ::Support::FrozenRecordHelpers::AbstractRecord + self.abstract_class = true + + type_registry ::Shared::TypeRegistry +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index cee23193..f17f0071 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -2,10 +2,11 @@ # @abstract The base model for the Meru API. class ApplicationRecord < ActiveRecord::Base - self.abstract_class = true + primary_abstract_class extend ArelHelpers extend DefinesMonadicOperation + include AssociationHelpers include CountFromSubquery include DistinctOnOrderValues @@ -35,14 +36,6 @@ def default_factory model_name.i18n_key end - def arel_text_contains(field, value) - arel_table[field].matches("%#{escape_ilike_needle(value)}%") - end - - def escape_ilike_needle(needle) - needle.gsub("%", "\\%").gsub("_", "\\_") - end - # @param [] records # @return [ActiveRecord::Relation] def limited_to(*records) diff --git a/app/models/community.rb b/app/models/community.rb index f588c198..cdb032f5 100644 --- a/app/models/community.rb +++ b/app/models/community.rb @@ -69,5 +69,13 @@ class << self # @note compatibility with {Submittable} implementations # @return [ActiveRecord::Relation] def sans_drafts = all + + # @note Compatibility with {ChecksContextualPermissions#visible_to}. Communities are always visible. + # @return [ActiveRecord::Relation] + def visible_to(...) = all + + # @note Compatibility with {Submittable} implementations. Communities cannot be drafts. + # @return [ActiveRecord::Relation] + def with_drafts = all end end diff --git a/app/models/concerns/checks_contextual_permissions.rb b/app/models/concerns/checks_contextual_permissions.rb index 0cacc9a1..3ae95e87 100644 --- a/app/models/concerns/checks_contextual_permissions.rb +++ b/app/models/concerns/checks_contextual_permissions.rb @@ -23,9 +23,7 @@ module ClassMethods # @param [User] user # @return [ActiveRecord::Relation] def readable_by(user) - # :nocov: return all if user.try(:has_global_admin_access?) - # :nocov: with_permitted_actions_for(user, "self.read") end @@ -35,13 +33,20 @@ def readable_by(user) # @param [User] user # @return [ActiveRecord::Relation] def updatable_by(user) - # :nocov: return all if user.try(:has_global_admin_access?) - # :nocov: with_permitted_actions_for(user, "self.update") end + # @param [User, AnonymousUser] user + # @return [ActiveRecord::Relation] + def visible_to(user) + return currently_visible if user.blank? || user.anonymous? || user.new_record? + return all if user.has_global_admin_access? + + left_outer_joins(:entity_visibility).where(arel_visible_to(user)) + end + # @param [User] user # @param [] actions # @return [ActiveRecord::Relation] @@ -50,5 +55,30 @@ def with_permitted_actions_for(user, *actions) where(contextual_permission_primary_key => constraint) end + + private + + # @param [User] user + # @return [Arel::Nodes::Case] + def arel_visible_to(user) + cppk = arel_table[contextual_permission_primary_key] + + permission_constraint = ContextualSinglePermission.for_hierarchical_type(model_name.to_s).with_permitted_actions_for(user, "self.read").select(:hierarchical_id) + + has_read_permission = arel_expr_in_query(cppk, permission_constraint) + + condition = has_read_permission + + if all.model == ::Entity + is_community = arel_table[:hierarchical_type].eq("Community") + + condition = arel_grouped(is_community.or(has_read_permission)) + end + + arel_case(EntityVisibility.arel_table[:active]) do |stmt| + stmt.when(true).then(true) + stmt.else(condition) + end + end end end diff --git a/app/models/concerns/contribution.rb b/app/models/concerns/contribution.rb index 7cb18cb5..eb49bc9c 100644 --- a/app/models/concerns/contribution.rb +++ b/app/models/concerns/contribution.rb @@ -16,11 +16,13 @@ module Contribution defines :contributables_key, type: Contributions::Types::ContributablesKey defines :contributable_foreign_key, type: Contributions::Types::ContributableForeignKey defines :contributable_klass_name, type: Contributions::Types::ContributableKlassName + defines :contributable_unique_by, type: Contributions::Types::UniqueByTuple contributable_key :"#{model_name.singular[/\A(.+)_contribution\z/, 1]}" contributables_key contributable_key.to_s.pluralize.to_sym contributable_foreign_key :"#{contributable_key}_id" contributable_klass_name model_name.to_s[/\A(.+)Contribution\z/, 1] + contributable_unique_by [:contributor_id, contributable_foreign_key, :role_id].freeze belongs_to :role, class_name: "ControlledVocabularyItem", inverse_of: table_name @@ -34,6 +36,8 @@ module Contribution scope :authors, -> { where(role_id: ControlledVocabularyItem.tagged_with("author").select(:id)) } + scope :currently_visible, -> { joins(contributable_key).merge(target_klass.currently_visible) } + scope :in_default_contributor_order, -> { joins(:contributor).merge(Contributor.in_default_order) } validates :role_id, uniqueness: { scope: %I[contributor_id #{contributable_foreign_key}] } @@ -49,49 +53,35 @@ module Contribution # @!attribute [r] contributable # @return [Contributable] - def contributable - __send__(contributable_key) - end + def contributable = __send__(contributable_key) # @!attribute [r] contributable_key # @return [Contributions::Types::ContributableKey] - def contributable_key - self.class.contributable_key - end + def contributable_key = self.class.contributable_key # @!attribute [r] contributable_foreign_key # @return [Contributions::Types::ContributableForeignKey] - def contributable_foreign_key - self.class.contributable_foreign_key - end + def contributable_foreign_key = self.class.contributable_foreign_key # @!attribute [r] contributable_klass_name # @return [Contributions::Types::ContributionKlassName] - def contributable_klass_name - self.class.contributable_klass_name - end + def contributable_klass_name = self.class.contributable_klass_name + + # @!attribute [r] contributable_unique_by + # @return [(Symbol, Symbol, Symbol)] + def contributable_unique_by = self.class.contributable_unique_by # @!attribute [r] contributables_key # @return [Contributions::Types::ContributablesKey] - def contributables_key - self.class.contributables_key - end + def contributables_key = self.class.contributables_key - def display_name - overridable_contributor_attribute :display_name - end + def display_name = overridable_contributor_attribute :display_name - def affiliation - overridable_contributor_attribute :affiliation, kind: :person - end + def affiliation = overridable_contributor_attribute :affiliation, kind: :person - def title - overridable_contributor_attribute :title, kind: :person - end + def title = overridable_contributor_attribute :title, kind: :person - def location - overridable_contributor_attribute :location, kind: :organization - end + def location = overridable_contributor_attribute :location, kind: :organization # @see Attributions::Collections::Manage # @see Attributions::Items::Manage @@ -102,6 +92,22 @@ def location call_operation("attributions.#{contributables_key}.manage", **options) end + # @return [Hash{Symbol => Object}] + def to_copy_tuple + metadata = self.metadata.as_json + + { + contributable_foreign_key => __send__(contributable_foreign_key), + role_id:, + kind:, + metadata:, + inner_position:, + outer_position:, + created_at:, + updated_at: Time.current, + } + end + private def overridable_contributor_attribute(attribute_name, kind: nil) @@ -162,6 +168,15 @@ def for_template_list(filter: "all", limit: Templates::Types::LIMIT_DEFAULT) base.preloaded_for_record_loading.limit(limit).in_default_contributor_order end + # @param [User, AnonymousUser] user + # @return [ActiveRecord::Relation] + def visible_to(user) + return currently_visible if user.blank? || user.anonymous? || user.new_record? + return all if user.has_global_admin_access? + + where(contributable_foreign_key => target_klass.visible_to(user).select(:id)) + end + def preloaded_for_record_loading super.includes(:role, :contributor) end diff --git a/app/models/concerns/submittable.rb b/app/models/concerns/submittable.rb index 29c5710f..321b390d 100644 --- a/app/models/concerns/submittable.rb +++ b/app/models/concerns/submittable.rb @@ -21,6 +21,7 @@ module Submittable # Used for default filtering of items. scope :sans_drafts, -> { not_submission_draft } + scope :with_drafts, -> { all } before_validation :enforce_hidden_if_draft! end diff --git a/app/models/contributor.rb b/app/models/contributor.rb index 6bac6138..e539fbbf 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Contributor < ApplicationRecord + include FullTextSearchable include HasEphemeralSystemSlug include HasHarvestModificationStatus include ImageUploader::Attachment.new(:image) @@ -11,6 +12,8 @@ class Contributor < ApplicationRecord strip_attributes only: %i[email orcid url] pg_enum! :kind, as: "contributor_kind" + pg_enum! :merge_source_status, as: "contributor_merge_source_status", default: "unmerged", allow_blank: false + pg_enum! :merge_target_status, as: "contributor_merge_target_status", default: "inactive", allow_blank: false, prefix: :merge_target attribute :links, Contributors::Link.to_array_type attribute :properties, Contributors::Properties.to_type @@ -27,6 +30,10 @@ class Contributor < ApplicationRecord has_many :item_contributions, dependent: :destroy, inverse_of: :contributor has_many :items, through: :item_contributions + belongs_to :merge_target, class_name: "Contributor", inverse_of: :merge_sources, optional: true + + has_many :merge_sources, class_name: "Contributor", foreign_key: :merge_target_id, inverse_of: :merge_target, dependent: :restrict_with_error + has_one :contributor_user_link, dependent: :destroy, inverse_of: :contributor has_one :user, through: :contributor_user_link @@ -35,8 +42,17 @@ class Contributor < ApplicationRecord scope :by_orcid, ->(orcid) { where(orcid:) } scope :unharvested, -> { where.not(id: HarvestContributor.harvested_ids) } + scope :claimed, -> { where.associated(:contributor_user_link) } + scope :unclaimed, -> { where.missing(:contributor_user_link) } + scope :in_default_order, -> { order(sort_name: :asc) } + full_text_searchable_with! :name + + before_validation :derive_merge_target_status! + + after_commit :notify_merge_target! + validates :identifier, :kind, presence: true validates :identifier, uniqueness: true validates :orcid, orcid: { allow_blank: true } @@ -45,21 +61,25 @@ class Contributor < ApplicationRecord delegate :display_name, to: :properties - # @param [Contributable] contributable - # @return [Dry::Monads::Result] - def attach!(contributable) - call_operation("contributors.attach", self, contributable) + monadic_matcher! def check_merge_to(other_contributor) + call_operation "contributors.check_merge", self, other_contributor + end + + def claimed? = contributor_user_link.present? + + monadic_operation! def copy_contributions + call_operation "contributors.copy_contributions", self end # @api private # @return [void] - def count_collection_contributions! + monadic_operation! def count_collection_contributions call_operation "contributors.count_collections", self end # @api private # @return [void] - def count_item_contributions! + monadic_operation! def count_item_contributions call_operation "contributors.count_items", self end @@ -78,6 +98,63 @@ def graphql_node_type organization? ? Types::OrganizationContributorType : Types::PersonContributorType end + # @see Contributors::LinkUser + # @see Contributors::UserLinker + # @return [Dry::Monads::Success(ContributorUserLink)] + monadic_operation! def link_user(user, linkage: "primary") + call_operation("contributors.link_user", self, user, linkage:) + end + + # @see Contributors::Merge + # @see Contributors::Merger + # @return [Dry::Monads::Success(Contributor)] + monadic_operation! def merge(other_contributor) + call_operation("contributors.merge", self, other_contributor) + end + + def mergeable? = unmerged? + + def merge_busy? = merge_source_busy? || merge_target_busy? + + def merge_prevents_destruction? = merge_target_busy? || merge_sources.exists? + + def merge_source_available? = !merge_source_busy? + + def merge_source_busy? = merging? || merged? + + def merge_started? = merge_source_busy? + + # @api private + # @param [Contributor] other_contributor + def merge_target?(other_contributor) = merge_target_id.present? && merge_target_id == other_contributor.id + + def merge_target_busy? = merge_target_active? + + def merge_target_available? = merge_source_available? + + # @return [void] + def merge_target_status_check! + derive_merge_target_status! + + update_columns(merge_target_status:) if merge_target_status_changed? + end + + # @see Contributors::MergeTo + # @see Contributors::MergeStarter + # @return [Dry::Monads::Success(void)] + monadic_operation! def merge_to(other_contributor, **options) + call_operation("contributors.merge_to", self, other_contributor, **options) + end + + # @param [Contributor] other_contributor + def merging_to?(other_contributor) = merging? && merge_target?(other_contributor) + + # @param [Contributor] other_contributor + # @return [Integer] the number of harvest contributors redirected + def redirect_harvesting_to!(other_contributor) + HarvestContributor.where(contributor_id: id).update_all(contributor_id: other_contributor.id) + end + def property_source(from) case from when /organization/ @@ -91,7 +168,7 @@ def property_source(from) # @api private # @return [void] - def recount_contributions! + monadic_operation! def recount_contributions call_operation "contributors.recount_contributions", self end @@ -100,10 +177,16 @@ def safe_name name.presence || "(Unknown #{display_kind})" end + # @see Contributions::Attacher + # @return [Contributor] + def to_attach = merge_target || self + def to_schematic_referent_label display_name end + def unclaimed? = !claimed? + # @!group Organization Accessors def legal_name @@ -140,6 +223,18 @@ def affiliation # @!endgroup + private + + # @return [void] + def derive_merge_target_status! + self.merge_target_status = merge_sources.exists? ? :active : :inactive + end + + # @return [void] + def notify_merge_target! + merge_target&.merge_target_status_check! + end + class << self # @param [String] input # @return [ActiveRecord::Relation] diff --git a/app/models/global_configuration.rb b/app/models/global_configuration.rb index 55a305a2..92b8d4aa 100644 --- a/app/models/global_configuration.rb +++ b/app/models/global_configuration.rb @@ -13,6 +13,7 @@ class GlobalConfiguration < ApplicationRecord include SiteLogoUploader::Attachment.new(:logo) include TimestampScopes + attribute :contributors, Settings::Contributors.to_type attribute :depositing, Settings::Depositing.to_type attribute :entities, Settings::Entities.to_type attribute :institution, Settings::Institution.to_type @@ -23,7 +24,7 @@ class GlobalConfiguration < ApplicationRecord validates :guard, presence: true, uniqueness: true - validates :entities, :institution, :site, :theme, store_model: true + validates :depositing, :entities, :institution, :site, :theme, store_model: true validates :contribution_role_configuration, presence: { on: :update } @@ -31,6 +32,10 @@ class GlobalConfiguration < ApplicationRecord before_validation :enforce_contribution_role_config! + after_save :refresh_current_record! + + delegate :missing_agreement?, to: :depositing, prefix: true + # @api private # @return [void] def reset! @@ -68,7 +73,26 @@ def maybe_clear_logo_mode! end end + # @return [void] + def refresh_current_record! + GlobalConfiguration.current! + end + class << self + # @!attribute [r] current + # A request-local instance of the current {GlobalConfiguration}. + # @see GlobalConfigurations::Current + # @return [GlobalConfiguration] + def current + GlobalConfigurations::Current.record + end + + # Fetch a fresh instance to set for {.current}. + # @return [GlobalConfiguration] + def current! + GlobalConfigurations::Current.record = fetch + end + # @return [GlobalConfiguration] def fetch GlobalConfiguration.singleton.first_or_create! diff --git a/app/models/harvest_metadata_format.rb b/app/models/harvest_metadata_format.rb index f934bbed..755d5056 100644 --- a/app/models/harvest_metadata_format.rb +++ b/app/models/harvest_metadata_format.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # A model that represents supported metadata formats for harvesting in Meru. -class HarvestMetadataFormat < Support::FrozenRecordHelpers::AbstractRecord +class HarvestMetadataFormat < ::ApplicationFrozenRecord include Dry::Core::Equalizer.new(:name) include Dry::Core::Memoizable diff --git a/app/models/harvest_protocol.rb b/app/models/harvest_protocol.rb index 401c462d..935789c3 100644 --- a/app/models/harvest_protocol.rb +++ b/app/models/harvest_protocol.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # A model that represents supported protocols for harvesting in Meru. -class HarvestProtocol < Support::FrozenRecordHelpers::AbstractRecord +class HarvestProtocol < ::ApplicationFrozenRecord include Dry::Core::Equalizer.new(:name) include Dry::Core::Memoizable diff --git a/app/models/harvesting/example.rb b/app/models/harvesting/example.rb index ee1b2212..2132b02a 100644 --- a/app/models/harvesting/example.rb +++ b/app/models/harvesting/example.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Harvesting - class Example < Support::FrozenRecordHelpers::AbstractRecord + class Example < ::ApplicationFrozenRecord include Dry::Core::Constants include Harvesting::Frozen::HasProtocolAndMetadata diff --git a/app/models/harvesting/testing/oai/esploro_record.rb b/app/models/harvesting/testing/oai/esploro_record.rb index a03c131f..508226fb 100644 --- a/app/models/harvesting/testing/oai/esploro_record.rb +++ b/app/models/harvesting/testing/oai/esploro_record.rb @@ -3,7 +3,7 @@ module Harvesting module Testing module OAI - class EsploroRecord < Support::FrozenRecordHelpers::AbstractRecord + class EsploroRecord < ::ApplicationFrozenRecord include Harvesting::Testing::OAI::SampleRecord record_schema!("esploro") do diff --git a/app/models/harvesting/testing/oai/jats_record.rb b/app/models/harvesting/testing/oai/jats_record.rb index 9b229740..2285a2d0 100644 --- a/app/models/harvesting/testing/oai/jats_record.rb +++ b/app/models/harvesting/testing/oai/jats_record.rb @@ -3,7 +3,7 @@ module Harvesting module Testing module OAI - class JATSRecord < Support::FrozenRecordHelpers::AbstractRecord + class JATSRecord < ::ApplicationFrozenRecord include Harvesting::Testing::OAI::SampleRecord record_schema!("jats") do diff --git a/app/models/harvesting/testing/oai/oaidc_record.rb b/app/models/harvesting/testing/oai/oaidc_record.rb index 2a1ced87..7167ed40 100644 --- a/app/models/harvesting/testing/oai/oaidc_record.rb +++ b/app/models/harvesting/testing/oai/oaidc_record.rb @@ -3,7 +3,7 @@ module Harvesting module Testing module OAI - class OAIDCRecord < Support::FrozenRecordHelpers::AbstractRecord + class OAIDCRecord < ::ApplicationFrozenRecord include Harvesting::Testing::OAI::SampleRecord record_schema!("oaidc") do diff --git a/app/models/harvesting/testing/oai/set.rb b/app/models/harvesting/testing/oai/set.rb index 1ab3eb38..82b70d9a 100644 --- a/app/models/harvesting/testing/oai/set.rb +++ b/app/models/harvesting/testing/oai/set.rb @@ -3,7 +3,7 @@ module Harvesting module Testing module OAI - class Set < Support::FrozenRecordHelpers::AbstractRecord + class Set < ::ApplicationFrozenRecord include Dry::Core::Equalizer.new(:spec) schema!(types: ::Harvesting::Testing::TypeRegistry) do diff --git a/app/models/harvesting/testing/provider_definition.rb b/app/models/harvesting/testing/provider_definition.rb index d46e9a0d..b6543945 100644 --- a/app/models/harvesting/testing/provider_definition.rb +++ b/app/models/harvesting/testing/provider_definition.rb @@ -2,7 +2,7 @@ module Harvesting module Testing - class ProviderDefinition < Support::FrozenRecordHelpers::AbstractRecord + class ProviderDefinition < ::ApplicationFrozenRecord include Harvesting::Frozen::HasProtocolAndMetadata schema!(types: ::Harvesting::Testing::TypeRegistry) do diff --git a/app/models/layout.rb b/app/models/layout.rb index be47ff57..ee589dcf 100644 --- a/app/models/layout.rb +++ b/app/models/layout.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Layout < Support::FrozenRecordHelpers::AbstractRecord +class Layout < ::ApplicationFrozenRecord include ActiveModel::Validations include Dry::Core::Equalizer.new(:layout_kind) include Dry::Core::Memoizable diff --git a/app/models/schema_property_kind.rb b/app/models/schema_property_kind.rb index f6b21e52..8835506b 100644 --- a/app/models/schema_property_kind.rb +++ b/app/models/schema_property_kind.rb @@ -8,7 +8,7 @@ # * `group`-type properties have the `group` kind and represent a special case, # being a property that contains nested properties. There is only one level # of nesting allowed. -class SchemaPropertyKind < Support::FrozenRecordHelpers::AbstractRecord +class SchemaPropertyKind < ::ApplicationFrozenRecord include Dry::Core::Equalizer.new(:name) include Dry::Core::Memoizable diff --git a/app/models/schema_property_type.rb b/app/models/schema_property_type.rb index f73194ef..0cae7058 100644 --- a/app/models/schema_property_type.rb +++ b/app/models/schema_property_type.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # A frozen record describing each possible type that a schema property can have. -class SchemaPropertyType < Support::FrozenRecordHelpers::AbstractRecord +class SchemaPropertyType < ::ApplicationFrozenRecord include Dry::Core::Constants include Dry::Core::Equalizer.new(:name) include Dry::Core::Memoizable diff --git a/app/models/static_ancestor_orderable_property.rb b/app/models/static_ancestor_orderable_property.rb index 95f30b59..002bc8db 100644 --- a/app/models/static_ancestor_orderable_property.rb +++ b/app/models/static_ancestor_orderable_property.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # A list of static properties that can be ordered by on {OrderingEntryCandidate} or a related model. -class StaticAncestorOrderableProperty < Support::FrozenRecordHelpers::AbstractRecord +class StaticAncestorOrderableProperty < ::ApplicationFrozenRecord include ActiveModel::Validations include Dry::Core::Memoizable include TranslatedFrozenRecord diff --git a/app/models/static_schema_definition.rb b/app/models/static_schema_definition.rb index cdff2b58..6f884d18 100644 --- a/app/models/static_schema_definition.rb +++ b/app/models/static_schema_definition.rb @@ -2,7 +2,7 @@ # @see SchemaDefinition # @see StaticSchemaVersion -class StaticSchemaDefinition < Support::FrozenRecordHelpers::AbstractRecord +class StaticSchemaDefinition < ::ApplicationFrozenRecord include ActiveModel::Validations include Dry::Core::Equalizer.new(:declaration) include Dry::Core::Memoizable diff --git a/app/models/static_schema_version.rb b/app/models/static_schema_version.rb index 440378bb..c6bdc829 100644 --- a/app/models/static_schema_version.rb +++ b/app/models/static_schema_version.rb @@ -3,7 +3,7 @@ # @see SchemaDefinition # @see SchemaVersion # @see StaticSchemaDefinition -class StaticSchemaVersion < Support::FrozenRecordHelpers::AbstractRecord +class StaticSchemaVersion < ::ApplicationFrozenRecord include ActiveModel::Validations include Dry::Core::Equalizer.new(:declaration) include Dry::Core::Memoizable diff --git a/app/models/submission.rb b/app/models/submission.rb index ea1dbca5..359bd66a 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -53,6 +53,13 @@ def agreement_accepted=(value) # :nocov: end + # @see Submissions::AttachContributions + # @see Submissions::ContributionsAttacher + # @return [Dry::Monads::Result] + monadic_operation! def attach_contributions + call_operation("submissions.attach_contributions", self) + end + # @note Called during rejection. # @see Submissions::Cleaner # @see Submissions::CleanUp @@ -78,6 +85,13 @@ def available_transitions # @return [Submissions::Status] def current_status = Submissions::Status.new(self) + # @see Submissions::EnforceAuthor + # @see Submissions::AuthorEnforcer + # @return [Dry::Monads::Success(void)] + monadic_operation! def enforce_author + call_operation("submissions.enforce_author", self) + end + # @see Submissions::Publish # @see Submissions::Publisher # @return [Dry::Monads::Result] diff --git a/app/models/submission_target.rb b/app/models/submission_target.rb index 01d01d41..bbf7b082 100644 --- a/app/models/submission_target.rb +++ b/app/models/submission_target.rb @@ -75,6 +75,12 @@ class SubmissionTarget < ApplicationRecord call_operation("depositor_agreements.accept", submission_target: self, user:) end + # @!attribute [r] agreement_content_with_fallback + # @return [String, nil] + def agreement_content_with_fallback + agreement_content.presence || GlobalConfiguration.current.depositing.agreement.presence + end + # @param [User] user # @return [DepositorAgreement] def agreement_for(user) @@ -93,6 +99,10 @@ def agreement_for(user) call_operation("submission_targets.configure", self, **options) end + def deposit_targets + direct_deposit? ? Dry::Core::Constants::EMPTY_ARRAY : submission_deposit_targets.map(&:entity) + end + # @param [User] user def has_accepted_agreement?(user) depositor_agreements.accepted.exists?(user:) if user.present? && user.authenticated? diff --git a/app/models/submission_target_reviewer.rb b/app/models/submission_target_reviewer.rb index 0407b7ca..5eb8e637 100644 --- a/app/models/submission_target_reviewer.rb +++ b/app/models/submission_target_reviewer.rb @@ -12,7 +12,9 @@ class SubmissionTargetReviewer < ApplicationRecord belongs_to :submission_target, inverse_of: :submission_target_reviewers, counter_cache: :reviewers_count belongs_to :user, inverse_of: :submission_target_reviewers - scope :in_default_order, -> { joins(:user) } + scope :in_default_order, -> { joins(:user).merge(User.in_default_order) } + + define_simple_lookups! :submission_target, :user after_create_commit :assign_reviewer_role! diff --git a/app/models/template.rb b/app/models/template.rb index e189c0ef..906ab1f1 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Template < Support::FrozenRecordHelpers::AbstractRecord +class Template < ::ApplicationFrozenRecord include ActiveModel::Validations include Dry::Core::Equalizer.new(:template_kind) include Dry::Core::Memoizable diff --git a/app/models/template_enum_property.rb b/app/models/template_enum_property.rb index 5ff147e6..85e252b6 100644 --- a/app/models/template_enum_property.rb +++ b/app/models/template_enum_property.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TemplateEnumProperty < Support::FrozenRecordHelpers::AbstractRecord +class TemplateEnumProperty < ::ApplicationFrozenRecord include Dry::Core::Equalizer.new(:name) include Dry::Core::Memoizable include ScopedTranslatableAttributes diff --git a/app/models/template_property.rb b/app/models/template_property.rb index 7f0a8aab..f08252af 100644 --- a/app/models/template_property.rb +++ b/app/models/template_property.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TemplateProperty < Support::FrozenRecordHelpers::AbstractRecord +class TemplateProperty < ::ApplicationFrozenRecord include Templates::Config::SourcedByTemplateKind schema!(types: ::Templates::TypeRegistry) do diff --git a/app/models/template_property_kind.rb b/app/models/template_property_kind.rb index ca4a771f..5a020b82 100644 --- a/app/models/template_property_kind.rb +++ b/app/models/template_property_kind.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TemplatePropertyKind < Support::FrozenRecordHelpers::AbstractRecord +class TemplatePropertyKind < ::ApplicationFrozenRecord include Dry::Core::Equalizer.new(:name) schema!(types: ::Templates::TypeRegistry) do diff --git a/app/models/template_slot.rb b/app/models/template_slot.rb index 7bf524e7..6c4f7119 100644 --- a/app/models/template_slot.rb +++ b/app/models/template_slot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TemplateSlot < Support::FrozenRecordHelpers::AbstractRecord +class TemplateSlot < ::ApplicationFrozenRecord include Templates::Config::SourcedByTemplateKind schema!(types: ::Templates::TypeRegistry) do diff --git a/app/models/user.rb b/app/models/user.rb index 7c08ce5a..b5b46e50 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -68,11 +68,14 @@ class User < ApplicationRecord after_save :synchronize_access_info! + scope :in_default_order, -> { by_role_priority.lazily_order(:name).order(id: :asc) } scope :global_admins, -> { where(arel_has_realm_role(:global_admin)) } scope :testing, -> { where_contains(email: "@example.") } scope :by_role_priority, -> { order(role_priority: :desc) } scope :by_inverse_role_priority, -> { order(role_priority: :asc) } + define_simple_lookups! :email + delegate :permissions, to: :global_access_control_list def anonymous? = false @@ -82,10 +85,33 @@ def authenticated? = true # @return [User] def authenticated = self + # @see Users::CreateDefaultAuthor + # @see Users::DefaultAuthorCreator + # @return [Dry::Monads::Success(Contributor)] + monadic_operation! def create_default_author + call_operation("users.create_default_author", self) + end + + # @return [(Integer, String, String)] + def default_tuple + [-role_priority, name, id] + end + # @param [HierarchicalEntity] entity # @return [ContextualPermission, nil] def contextual_permissions_for(entity) = ContextualPermission.fetch(self, entity) + # @see Users::FetchAuthor + # @see Users::AuthorFetcher + # @return [Dry::Monads::Success(Contributor)] + monadic_operation! def fetch_author + call_operation("users.fetch_author", self) + end + + def has_author? = primary_contributor.present? + + def has_admin_access? = has_global_admin_access? + def has_allowed_action?(name) = name.to_s.in?(allowed_actions) def has_role?(name) = name.to_s.in? roles @@ -94,6 +120,15 @@ def has_global_admin_access? = has_role? :global_admin def has_any_upload_access? = has_global_admin_access? || has_granted_asset_creation? + def has_no_author? = !has_author? + + # @note Inverse of {Contributor#link_user}. + monadic_operation! def link_contributor(contributor, **options) + call_operation("contributors.link_user", contributor, self, **options) + end + + def may_claim_author? = has_allowed_action?("contributors.claim") && has_no_author? + def system_slug_id = keycloak_id # @!scope private diff --git a/app/operations/contributors/attach.rb b/app/operations/contributors/attach.rb deleted file mode 100644 index 143bff31..00000000 --- a/app/operations/contributors/attach.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Contributors - # @deprecated Use {Contributions::Attach} instead. - class Attach < Support::SimpleServiceOperation - service_klass Contributions::Attacher - end -end diff --git a/app/operations/contributors/attach_collection.rb b/app/operations/contributors/attach_collection.rb deleted file mode 100644 index a2163541..00000000 --- a/app/operations/contributors/attach_collection.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Contributors - # @deprecated Use {Contributions::Attach} instead. - class AttachCollection < Support::SimpleServiceOperation - service_klass Contributions::Attacher - end -end diff --git a/app/operations/contributors/attach_item.rb b/app/operations/contributors/attach_item.rb deleted file mode 100644 index 74332014..00000000 --- a/app/operations/contributors/attach_item.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Contributors - # @deprecated Use {Contributions::Attach} instead. - class AttachItem < Support::SimpleServiceOperation - service_klass Contributions::Attacher - end -end diff --git a/app/operations/contributors/check_merge.rb b/app/operations/contributors/check_merge.rb new file mode 100644 index 00000000..918da74e --- /dev/null +++ b/app/operations/contributors/check_merge.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::MergeChecker + class CheckMerge < Support::SimpleServiceOperation + service_klass Contributors::MergeChecker + end +end diff --git a/app/operations/contributors/copy_contributions.rb b/app/operations/contributors/copy_contributions.rb new file mode 100644 index 00000000..d03a83f0 --- /dev/null +++ b/app/operations/contributors/copy_contributions.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::ContributionsCopier + class CopyContributions < Support::SimpleServiceOperation + service_klass Contributors::ContributionsCopier + end +end diff --git a/app/operations/contributors/link_user.rb b/app/operations/contributors/link_user.rb new file mode 100644 index 00000000..f0be128c --- /dev/null +++ b/app/operations/contributors/link_user.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::UserLinker + class LinkUser < Support::SimpleServiceOperation + service_klass Contributors::UserLinker + end +end diff --git a/app/operations/contributors/merge.rb b/app/operations/contributors/merge.rb new file mode 100644 index 00000000..a2221352 --- /dev/null +++ b/app/operations/contributors/merge.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::Merger + class Merge < Support::SimpleServiceOperation + service_klass Contributors::Merger + end +end diff --git a/app/operations/contributors/merge_to.rb b/app/operations/contributors/merge_to.rb new file mode 100644 index 00000000..f7cd5dca --- /dev/null +++ b/app/operations/contributors/merge_to.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::MergeStarter + class MergeTo < Support::SimpleServiceOperation + service_klass Contributors::MergeStarter + end +end diff --git a/app/operations/filtering/run.rb b/app/operations/filtering/run.rb deleted file mode 100644 index 9297cdf7..00000000 --- a/app/operations/filtering/run.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Filtering - class Run < Support::SimpleServiceOperation - service_klass Filtering::Runner - - def rental_items(**options) - call(RentalItem, options:) - end - end -end diff --git a/app/operations/filtering/type_container.rb b/app/operations/filtering/type_container.rb new file mode 100644 index 00000000..97c4d9db --- /dev/null +++ b/app/operations/filtering/type_container.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Filtering + # A container for all Meru-specific filtering types. + class TypeContainer < Support::Filtering::TypeContainer + add_models!( + "Collection", + "Community", + "Contributor", + "ControlledVocabulary", + "DepositorRequest", + "HarvestSource", + "Item", + "Permalink", + "Role", + "SchemaDefinition", + "SchemaVersion", + "Submission", + "SubmissionComment", + "SubmissionReview", + "SubmissionTarget", + "SubmissionTargetReviewer" + ) + + add_enum_types!( + ::Types::DepositorRequestStateType, + ::Types::HarvestMessageLevelType, + ::Types::SubmissionCommentRoleType, + ::Types::SubmissionDepositModeType, + ::Types::SubmissionReviewStateType, + ::Types::SubmissionStateType, + ::Types::SubmissionTargetStateType + ) + + # @return [void] + compile! def add_interfaces! + add! :any_entity, ::Entities::Types::Entity.gql_loads("::Types::EntityType") + + add! :any_entities, ::Entities::Types::Entities.gql_loads("::Types::EntityType") + + add! :child_entity, ::Entities::Types::Entity.gql_loads("::Types::ChildEntityType") + end + end +end diff --git a/app/operations/harvesting/contributions/attach.rb b/app/operations/harvesting/contributions/attach.rb index eb89dc45..f25b9e1d 100644 --- a/app/operations/harvesting/contributions/attach.rb +++ b/app/operations/harvesting/contributions/attach.rb @@ -11,7 +11,6 @@ class Attach include Dry::Monads[:do, :result] include MonadicPersistence include MeruAPI::Deps[ - attach: "contributors.attach", connect_or_create: "harvesting.contributors.connect_or_create", ] @@ -23,7 +22,7 @@ def call(harvest_contribution, contributable) options = harvest_contribution.to_attach_options - attach.call(contributor, contributable, **options) + contributable.attach_contribution(contributor, **options) end end end diff --git a/app/operations/mutations/contracts/contributor_claim.rb b/app/operations/mutations/contracts/contributor_claim.rb new file mode 100644 index 00000000..42b1e045 --- /dev/null +++ b/app/operations/mutations/contracts/contributor_claim.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Mutations + module Contracts + # @see Mutations::ContributorClaim + # @see Mutations::Operations::ContributorClaim + class ContributorClaim < MutationOperations::Contract + json do + required(:contributor).value(:contributor) + end + end + end +end diff --git a/app/operations/mutations/contracts/contributor_merge.rb b/app/operations/mutations/contracts/contributor_merge.rb new file mode 100644 index 00000000..4dd6c379 --- /dev/null +++ b/app/operations/mutations/contracts/contributor_merge.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Contracts + # @see Mutations::ContributorMerge + # @see Mutations::Operations::ContributorMerge + class ContributorMerge < MutationOperations::Contract + json do + required(:source).value(:contributor) + required(:target).value(:contributor) + end + + rule :source do + value.check_merge_to(values[:target]) do |m| + m.success do |value| + base.failure(:contributor_merge_in_progress) if value == :existing + end + + m.failure do |key, *_| + case key + in :same_contributor + base.failure(:contributor_merge_same_contributor) + in :source_merging + base.failure(:contributor_merge_source_merging) + in :target_merging + base.failure(:contributor_merge_target_merging) + else + # :nocov: + raise "Unexpected validation error key: #{key.inspect}" + # :nocov: + end + end + end + end + end + end +end diff --git a/app/operations/mutations/contracts/submission_target_configure.rb b/app/operations/mutations/contracts/submission_target_configure.rb index 592278e1..8dd1cbd5 100644 --- a/app/operations/mutations/contracts/submission_target_configure.rb +++ b/app/operations/mutations/contracts/submission_target_configure.rb @@ -7,6 +7,8 @@ module Contracts class SubmissionTargetConfigure < MutationOperations::Contract json do required(:configurable).value(::SubmissionTargets::Types::Configurable) + required(:entity).value(:any_entity) + required(:submission_target).value(:submission_target) required(:deposit_mode).value(:submission_deposit_mode) required(:deposit_targets).array(:any_entity) required(:schema_versions).array(:schema_version) { filled? } @@ -23,7 +25,7 @@ class SubmissionTargetConfigure < MutationOperations::Contract end rule(:agreement_content) do - key.failure(:filled?) if values[:agreement_required] && value.blank? + key.failure(:filled?) if values[:agreement_required] && value.blank? && GlobalConfiguration.current.depositing_missing_agreement? end rule(:deposit_targets) do diff --git a/app/operations/mutations/contracts/update_global_configuration.rb b/app/operations/mutations/contracts/update_global_configuration.rb index 8fea1846..b79646e6 100644 --- a/app/operations/mutations/contracts/update_global_configuration.rb +++ b/app/operations/mutations/contracts/update_global_configuration.rb @@ -11,8 +11,14 @@ class UpdateGlobalConfiguration < ApplicationContract optional(:other_item).value(:controlled_vocabulary_item) end + optional(:contributors).maybe(:hash) do + required(:claimable).value(:bool) + required(:owner_updatable).value(:bool) + end + optional(:depositing).maybe(:hash) do required(:agreement).value(:safe_string) + required(:enabled).value(:bool) end optional(:entities).maybe(:hash) do diff --git a/app/operations/mutations/operations/contributor_claim.rb b/app/operations/mutations/operations/contributor_claim.rb new file mode 100644 index 00000000..11665b8e --- /dev/null +++ b/app/operations/mutations/operations/contributor_claim.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module Operations + # @see Mutations::ContributorClaim + class ContributorClaim + include MutationOperations::Base + + use_contract! :contributor_claim + + authorizes! :contributor, with: :claim? + authorizes! :current_user, with: :claim_contributor? + + # @param [Contributor] contributor + # @return [void] + def call(contributor:, **) + with_attached_result! :contributor_user_link, contributor.link_user(current_user, linkage: :primary) + + attach! :contributor, contributor.reload + attach! :user, current_user.reload + end + end + end +end diff --git a/app/operations/mutations/operations/contributor_merge.rb b/app/operations/mutations/operations/contributor_merge.rb new file mode 100644 index 00000000..2cfe054a --- /dev/null +++ b/app/operations/mutations/operations/contributor_merge.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Operations + # @see Mutations::ContributorMerge + class ContributorMerge + include MutationOperations::Base + + use_contract! :contributor_merge + + authorizes! :source, with: :merge_source? + authorizes! :target, with: :merge_target? + + # @param [Contributor] source + # @param [Contributor] target + # @return [void] + def call(source:, target:, **) + check_result!(source.merge_to(target, enqueue_merge_job: true)) + + attach! :source, source.reload + attach! :target, target.reload + end + end + end +end diff --git a/app/operations/mutations/operations/contributor_user_link_upsert.rb b/app/operations/mutations/operations/contributor_user_link_upsert.rb index a370c170..d1e1bc41 100644 --- a/app/operations/mutations/operations/contributor_user_link_upsert.rb +++ b/app/operations/mutations/operations/contributor_user_link_upsert.rb @@ -8,18 +8,14 @@ class ContributorUserLinkUpsert use_contract! :contributor_user_link_upsert - authorizes! :contributor, with: :update? + authorizes! :contributor, with: :link_user? # @param [Contributor] contributor # @param [User] user # @param ["primary", "auxiliary"] linkage # @return [void] def call(contributor:, user:, linkage:, **) - link = ContributorUserLink.where(contributor:).first_or_initialize - - assign_attributes!(link, user:, linkage:) - - persist_model! link, attach_to: :contributor_user_link + with_attached_result! :contributor_user_link, contributor.link_user(user, linkage:) attach! :contributor, contributor.reload attach! :user, user.reload diff --git a/app/operations/mutations/operations/submission_target_configure.rb b/app/operations/mutations/operations/submission_target_configure.rb index d38eef35..86aedace 100644 --- a/app/operations/mutations/operations/submission_target_configure.rb +++ b/app/operations/mutations/operations/submission_target_configure.rb @@ -18,6 +18,20 @@ def call(configurable:, **attrs) with_attached_result!(:submission_target, result) end + + before_prepare def extract_configurable_parts + args[:entity], args[:submission_target] = + case args[:configurable] + in SubmissionTarget => submission_target + [submission_target.entity, submission_target] + in HierarchicalEntity => entity + [entity, entity.fetch_submission_target!] + else + # :nocov: + raise "Unexpected configurable type: #{inputs[:configurable].class}" + # :nocov: + end + end end end end diff --git a/app/operations/mutations/operations/update_global_configuration.rb b/app/operations/mutations/operations/update_global_configuration.rb index 4434f571..9c95c61c 100644 --- a/app/operations/mutations/operations/update_global_configuration.rb +++ b/app/operations/mutations/operations/update_global_configuration.rb @@ -14,7 +14,9 @@ class UpdateGlobalConfiguration authorizes! :config, with: :update? # @param [GlobalConfiguration] config - def call(config:, contribution_roles: nil, depositing: nil, entities: nil, institution: nil, site: nil, theme: nil, **args) + def call(config:, contribution_roles: nil, contributors: nil, depositing: nil, entities: nil, institution: nil, site: nil, theme: nil, **args) + config.contributors = contributors if contributors.present? + config.depositing = depositing if depositing.present? config.entities = entities if entities.present? diff --git a/app/operations/mutations/operations/upsert_contribution.rb b/app/operations/mutations/operations/upsert_contribution.rb index 7457d957..7eb8db19 100644 --- a/app/operations/mutations/operations/upsert_contribution.rb +++ b/app/operations/mutations/operations/upsert_contribution.rb @@ -7,9 +7,9 @@ class UpsertContribution use_contract! :upsert_contribution - def call(contributable:, contributor:, **inputs) - authorize contributable, :update? + authorizes! :contributable, with: :update? + def call(contributable:, contributor:, **inputs) result = contributable.attach_contribution(contributor, **inputs) with_attached_result! :contribution, result diff --git a/app/operations/roles/calculate_system.rb b/app/operations/roles/calculate_system.rb new file mode 100644 index 00000000..fcebdb06 --- /dev/null +++ b/app/operations/roles/calculate_system.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Roles + # @see Roles::SystemCalculator + class CalculateSystem < Support::SimpleServiceOperation + service_klass Roles::SystemCalculator + end +end diff --git a/app/operations/roles/calculate_system_roles.rb b/app/operations/roles/calculate_system_roles.rb deleted file mode 100644 index 82847999..00000000 --- a/app/operations/roles/calculate_system_roles.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module Roles - # rubocop:disable Metrics/MethodLength - class CalculateSystemRoles - def call - mapper = Roles::Mapper.new - - roles = mapper.call do |m| - m.role! :admin do |r| - r.acl ?* - - r.gacl ?* - end - - m.role! :manager do |r| - # A manager can do anything under its assigned hierarchy - r.acl ?* do |acl| - # Except delete the thing it is assigned to. - acl.deny! "self.delete" - end - - r.gacl "admin.access", "contributors.*", "roles.read", "users.read" - end - - m.role! :editor do |r| - r.acl do |acl| - # An editor can read anything under its assigned hierarchy - acl.allow! "*.read", "*.assets.read" - # An editor can update any assigned entity as well as its subcollections and items - acl.allow! "self.update", "collections.update", "items.update" - # An editor can update any asset - acl.allow! "*.assets.update" - end - - r.gacl "admin.access", "contributors.read", "contributors.create", "contributors.update" do |gacl| - gacl.deny! "contributors.delete" - - gacl.allow! "roles.read" - end - end - - m.role! :reviewer do |r| - r.acl do |acl| - # A reviewer can read anything under its assigned hierarchy - acl.allow! "*.read" - - # A reviewer can review any assigned entity as well as its subcollections and items - acl.allow! "self.review", "collections.review", "items.review" - - # A reviewer can read any assets under its assigned hierarchy - acl.allow! "*.assets.read" - end - - r.gacl "admin.access", "contributors.read", "roles.read" - end - - m.role! :depositor do |r| - r.acl do |acl| - # A depositor can read anything under its assigned hierarchy - acl.allow! "*.read" - - # A depositor can deposit to any assigned entity as well as its subcollections and items - acl.allow! "self.deposit", "collections.deposit", "items.deposit" - - # A depositor can read any assets under its assigned hierarchy - acl.allow! "*.assets.read" - end - - r.gacl "admin.access", "contributors.read", "roles.read" - end - - m.role! :reader do |r| - r.acl "*.read", "*.assets.read" - - r.gacl "contributors.read", "roles.read" - end - end - - return roles - end - end - # rubocop:enable Metrics/MethodLength -end diff --git a/app/operations/roles/dump_calculated_system_roles.rb b/app/operations/roles/dump_calculated_system_roles.rb index 8dc643b7..f3c8e2c7 100644 --- a/app/operations/roles/dump_calculated_system_roles.rb +++ b/app/operations/roles/dump_calculated_system_roles.rb @@ -4,11 +4,11 @@ module Roles # Generate a file at `lib/frozen_record/system_roles.yml` with the results of # {Roles::CalculateSystemRoles calculating the system roles}. This file is # consumed by {SystemRole} and further used in {Roles::Sync} to ensure that - # the WDP-API's default roles are pristine. + # the Meru-API's default roles are pristine. class DumpCalculatedSystemRoles - include Dry::Monads[:result] + include Dry::Monads[:result, :do] include MeruAPI::Deps[ - calculate: "roles.calculate_system_roles", + calculate: "roles.calculate_system", ] DUMP_PATH = Rails.root.join("lib", "frozen_record", "system_roles.yml") @@ -16,7 +16,7 @@ class DumpCalculatedSystemRoles # @return [Dry::Monads::Result] def call # :nocov: - roles = calculate.call + roles = yield calculate.call dump = roles.map(&:stringify_keys).to_yaml diff --git a/app/operations/submissions/attach_contributions.rb b/app/operations/submissions/attach_contributions.rb new file mode 100644 index 00000000..d22ea4bc --- /dev/null +++ b/app/operations/submissions/attach_contributions.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Submissions + # @see Submissions::ContributionsAttacher + class AttachContributions < Support::SimpleServiceOperation + service_klass Submissions::ContributionsAttacher + end +end diff --git a/app/operations/submissions/enforce_author.rb b/app/operations/submissions/enforce_author.rb new file mode 100644 index 00000000..d151516c --- /dev/null +++ b/app/operations/submissions/enforce_author.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Submissions + # @see Submissions::AuthorEnforcer + class EnforceAuthor < Support::SimpleServiceOperation + service_klass Submissions::AuthorEnforcer + end +end diff --git a/app/operations/users/create_default_author.rb b/app/operations/users/create_default_author.rb new file mode 100644 index 00000000..a789442c --- /dev/null +++ b/app/operations/users/create_default_author.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Users + # @see Users::DefaultAuthorCreator + class CreateDefaultAuthor < Support::SimpleServiceOperation + service_klass Users::DefaultAuthorCreator + end +end diff --git a/app/operations/users/fetch_author.rb b/app/operations/users/fetch_author.rb new file mode 100644 index 00000000..b70fb6db --- /dev/null +++ b/app/operations/users/fetch_author.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Users + # @see Users::AuthorFetcher + class FetchAuthor < Support::SimpleServiceOperation + service_klass Users::AuthorFetcher + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 8e632c4c..a0d6c6f7 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -6,7 +6,7 @@ class ApplicationPolicy < ActionPolicy::Base extend Dry::Core::ClassAttributes - defines :always_readable, :readable_in_dev, type: Roles::Types::Bool + defines :always_readable, :authenticated_readable, :readable_in_dev, type: Roles::Types::Bool # @!attribute [r] always_readable # @!scope class @@ -14,6 +14,12 @@ class ApplicationPolicy < ActionPolicy::Base # @return [Boolean] always_readable false + # @!attribute [r] authenticated_readable + # @!scope class + # Whether the record is readable by any authenticated user, regardless of other permissions. + # @return [Boolean] + authenticated_readable false + # @!attribute [r] readable_in_dev # @!scope class # Whether the record is readable in development mode. @@ -42,14 +48,15 @@ def initialize(record, user: AnonymousUser.new, **options) # @see #show? def read? = admin_or_owns_resource? + def show? = read? + # @!parse [ruby] - # alias show? read? - # alias index? read? - alias_rule :show?, :index?, to: :read? + # alias index? show? + alias_rule :index?, to: :show? # Sometimes we need to allow read access specifically for use with mutation arguments # in a way that differs from normal read access. This happens in other projects, but - # not here yet. This is here for support with {Types::AbstractModel.authorized?}. + # not here yet. This is here for support with {Types::BaseModel.authorized?}. # # For the sake of mutations, assume arguments provided can always be read and worry # about authorizing within the context of the mutation. @@ -97,6 +104,11 @@ def anonymous? = user.anonymous? def authenticated? = user.authenticated? + def authenticated_readable? = self.class.authenticated_readable + + # @return [GlobalConfiguration] + def current_global_configuration = GlobalConfiguration.current + def has_any_access_management_permissions? = user.can_manage_access_globally? || user.can_manage_access_contextually? # Whether the user has global admin access @@ -170,6 +182,7 @@ def allow_admin_or_for_user_owned! # @return [void] def allow_public_reading! allow! if always_readable? || readable_in_dev? + allow! if authenticated_readable? && authenticated? end # @api private @@ -221,6 +234,8 @@ def resolve_scope_for_admin(relation) def resolve_scope_for_non_admin(relation) if always_readable? || readable_in_dev? relation.all + elsif authenticated_readable? && authenticated? + relation.all else relation.none end @@ -257,6 +272,14 @@ def always_readable! always_readable true end + # Specify that the record is readable by any authenticated user. + # + # @see .authenticated_readable + # @return [void] + def authenticated_readable! + authenticated_readable true + end + # Specify that the record is readable in development mode. # # @see .readable_in_dev diff --git a/app/policies/collection_contribution_policy.rb b/app/policies/collection_contribution_policy.rb index 06213d25..312d1004 100644 --- a/app/policies/collection_contribution_policy.rb +++ b/app/policies/collection_contribution_policy.rb @@ -2,15 +2,5 @@ # @see CollectionContribution class CollectionContributionPolicy < ApplicationPolicy - include PubliclyScopedPolicy - - def read? = allowed_to?(:update?, record.collection) - - def show? = allowed_to?(:show?, record.collection) - - def create? = allowed_to?(:update?, record.collection) - - def update? = allowed_to?(:update?, record.collection) - - def destroy? = allowed_to?(:update?, record.collection) + include ContributionPolicy end diff --git a/app/policies/community_policy.rb b/app/policies/community_policy.rb index 3abbe8af..15003e4e 100644 --- a/app/policies/community_policy.rb +++ b/app/policies/community_policy.rb @@ -11,8 +11,4 @@ def create? = has_allowed_action?("communities.create") def update? = has_allowed_action?("communities.update") || super def destroy? = has_allowed_action?("communities.delete") || super - - private - - def show_full_entity_scope? = has_allowed_action?("communities.read") || super end diff --git a/app/policies/concerns/contribution_policy.rb b/app/policies/concerns/contribution_policy.rb new file mode 100644 index 00000000..aaf73ed3 --- /dev/null +++ b/app/policies/concerns/contribution_policy.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ContributionPolicy + extend ActiveSupport::Concern + + def read? = allowed_to?(:read?, record.contributable) + + def show? = allowed_to?(:show?, record.contributable) + + def create? = allowed_to?(:update?, record.contributable) + + def update? = allowed_to?(:update?, record.contributable) + + def destroy? = allowed_to?(:update?, record.contributable) + + private + + def resolve_scope_for_non_admin(relation) + relation.visible_to(user) + end +end diff --git a/app/policies/contributor_policy.rb b/app/policies/contributor_policy.rb index e9ec6e5e..d2f828a8 100644 --- a/app/policies/contributor_policy.rb +++ b/app/policies/contributor_policy.rb @@ -1,23 +1,55 @@ # frozen_string_literal: true class ContributorPolicy < ApplicationPolicy - pre_check :allow_any_admin! + include PubliclyScopedPolicy + + pre_check :prevent_merge_source_interruption!, except: %i[show? read? read_for_mutation? index? merge_source? merge_target?] + pre_check :prevent_merge_target_interruption!, only: %i[destroy?] + pre_check :deny_if_claiming_disabled!, only: %i[claim?] + pre_check :allow_any_admin!, except: %i[claim?] + pre_check :deny_anonymous!, except: %i[show?] + + def read? = super || has_allowed_action?("contributors.read") def show? = true def create? = has_allowed_action?("contributors.create") - def update? = has_allowed_action?("contributors.update") + def update? = allowed_to_update_contributors? || allowed_to_update_claimed? def destroy? = has_allowed_action?("contributors.delete") + def claim? = record.unclaimed? && user.may_claim_author? + + def link_user? = allowed_to_update_contributors? + + def merge_source? = allowed_to_merge_contributors? && record.merge_source_available? + + def merge_target? = allowed_to_merge_contributors? && record.merge_target_available? + private - def resolve_scope_for_authenticated(relation) - relation.all + def allowed_to_merge_contributors? = has_allowed_action?("contributors.merge") + + def allowed_to_update_contributors? = has_allowed_action?("contributors.update") + + def allowed_to_update_claimed? = owner_updatable? && record.user == user + + def claiming_enabled? = current_global_configuration.contributors.claimable? + + def owner_updatable? = current_global_configuration.contributors.owner_updatable? + + # @return [void] + def deny_if_claiming_disabled! + deny! unless claiming_enabled? + end + + # @return [void] + def prevent_merge_source_interruption! + deny! if record.merge_source_busy? end - def resolve_scope_for_anonymous(relation) - relation.none + def prevent_merge_target_interruption! + deny! if record.merge_prevents_destruction? end end diff --git a/app/policies/contributor_user_link_policy.rb b/app/policies/contributor_user_link_policy.rb index 67496c82..4b8b31a2 100644 --- a/app/policies/contributor_user_link_policy.rb +++ b/app/policies/contributor_user_link_policy.rb @@ -2,7 +2,29 @@ # @see ContributorUserLink class ContributorUserLinkPolicy < ApplicationPolicy - include PubliclyScopedPolicy + pre_check :allow_any_admin!, except: %i[create? update?] - pre_check :allow_any_admin! + def read? = update_contributor? || read_user? + + def show? = read? + + def create? = false + + def update? = false + + def destroy? = has_allowed_action?("contributors.destroy") || allowed_to?(:destroy?, record.contributor) + + private + + def update_contributor? = has_allowed_action?("contributors.update") || allowed_to?(:update?, record.contributor) + + def read_user? = has_allowed_action?("users.read") || allowed_to?(:read?, record.user) + + def resolve_scope_for_authenticated(relation) + if has_allowed_action?("contributors.update") || has_allowed_action?("users.read") + relation.all + else + relation.where(user:) + end + end end diff --git a/app/policies/hierarchical_entity_policy.rb b/app/policies/hierarchical_entity_policy.rb index 9f126383..ccf037c8 100644 --- a/app/policies/hierarchical_entity_policy.rb +++ b/app/policies/hierarchical_entity_policy.rb @@ -4,7 +4,7 @@ # @see HierarchicalEntity class HierarchicalEntityPolicy < ApplicationPolicy pre_check :deny_anonymous!, except: %i[index? show? read_assets?] - pre_check :deny_submission_drafts!, only: %i[reparent? alter_schema_version?] + pre_check :deny_submission_drafts!, only: %i[reparent? alter_schema_version? create_collections? create_items? destroy?] pre_check :allow_any_admin! pre_check :allow_if_depositor_on_draft!, only: %i[read? show? update?] @@ -24,13 +24,7 @@ def initialize(...) def read? = has_permission?(:read) - def show? - return true if read? - - return true unless record.respond_to?(:currently_visible?) - - record.currently_visible? - end + def show? = record.currently_visible? || read? alias_rule :index?, to: :show? @@ -97,17 +91,7 @@ def has_hierarchical_scoped_permission?(scope_name, permission_name) AccessGrant.for_user(user).with_allowed_action?(name: action_name, entity: record) end - def show_full_entity_scope? = has_allowed_action?("admin.access") - - def resolve_scope_for_authenticated(relation) - if show_full_entity_scope? - relation.all - else - relation.currently_visible - end - end - - def resolve_scope_for_anonymous(relation) - relation.currently_visible + def resolve_scope_for_non_admin(relation) + relation.visible_to(user) end end diff --git a/app/policies/item_contribution_policy.rb b/app/policies/item_contribution_policy.rb index 852f11f1..e753d347 100644 --- a/app/policies/item_contribution_policy.rb +++ b/app/policies/item_contribution_policy.rb @@ -2,15 +2,5 @@ # @see ItemContribution class ItemContributionPolicy < ApplicationPolicy - include PubliclyScopedPolicy - - def read? = allowed_to?(:update?, record.item) - - def show? = allowed_to?(:show?, record.item) - - def create? = allowed_to?(:update?, record.item) - - def update? = allowed_to?(:update?, record.item) - - def destroy? = allowed_to?(:update?, record.item) + include ContributionPolicy end diff --git a/app/policies/submission_target_reviewer_policy.rb b/app/policies/submission_target_reviewer_policy.rb index 6d1d8f56..95fb64ff 100644 --- a/app/policies/submission_target_reviewer_policy.rb +++ b/app/policies/submission_target_reviewer_policy.rb @@ -2,7 +2,7 @@ # @see SubmissionTargetReviewer class SubmissionTargetReviewerPolicy < ApplicationPolicy - always_readable! + authenticated_readable! def create? = allowed_to?(:manage_reviewers?, record.submission_target) diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 9a8be5d1..f424b566 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -3,15 +3,20 @@ # Presently, user creation and destruction is managed in Keycloak # and cannot be handled directly in Meru. Permissions reflect this. class UserPolicy < ApplicationPolicy - pre_check :allow_any_admin!, except: %i[create? destroy? receive_review_requests? revalidate_instance?] - pre_check :deny_anonymous!, only: %i[update? receive_review_requests? reset_password? revalidate_instance?] - pre_check :allow_authenticated_self_action!, only: %i[read? update? reset_password?] + pre_check :allow_any_admin!, except: %i[create? destroy? receive_review_requests? revalidate_instance? claim_contributor?] + pre_check :deny_anonymous!, only: %i[update? receive_review_requests? reset_password? revalidate_instance? claim_contributor?] + pre_check :allow_authenticated_self_action!, only: %i[read? show? update? reset_password?] + pre_check :allow_reviewers!, only: %i[read? show?] def read? = record.anonymous? || has_allowed_action?("users.read") || has_any_access_management_permissions? def update? = has_allowed_action?("users.update") - def receive_review_requests? = admin_record? || record.submission_target_reviewers.exists? + def access_admin? = has_allowed_action?("admin.access") && record.has_allowed_action?("admin.access") + + def claim_contributor? = record.authenticated? && record.may_claim_author? + + def receive_review_requests? = admin_record? || record_is_reviewer? def reset_password? = has_allowed_action?("users.update") @@ -26,10 +31,17 @@ def allow_authenticated_self_action! allow! if authenticated_self_action? end + # @return [void] + def allow_reviewers! + allow! if authenticated? && record_is_reviewer? + end + def admin_record? = record.has_global_admin_access? def authenticated_self_action? = record == user + def record_is_reviewer? = record.authenticated? && record.submission_target_reviewers.exists? + def resolve_scope_for_authenticated(relation) if has_allowed_action?("users.read") relation.all diff --git a/app/services/contributions/attacher.rb b/app/services/contributions/attacher.rb index 095241fa..55f01788 100644 --- a/app/services/contributions/attacher.rb +++ b/app/services/contributions/attacher.rb @@ -6,7 +6,7 @@ module Contributions # @see Contributions::Attach class Attacher < Support::HookBased::Actor include Dry::Initializer[undefined: false].define -> do - param :contributor, Contributions::Types::Contributor + param :contributor, Contributions::Types::Contributor, as: :provided_contributor param :contributable, Contributions::Types::Contributable @@ -19,6 +19,9 @@ class Attacher < Support::HookBased::Actor standard_execution! + # @return [::Contributor] + attr_reader :contributor + # @return [::Contribution] attr_reader :contribution @@ -54,6 +57,8 @@ def call end wrapped_hook! def prepare + @contributor = provided_contributor.to_attach + @role = provided_role || call_operation!("contribution_roles.fetch_default", contributable:) @tuple = build_tuple diff --git a/app/services/contributions/types.rb b/app/services/contributions/types.rb index d94ac110..6eba8100 100644 --- a/app/services/contributions/types.rb +++ b/app/services/contributions/types.rb @@ -21,5 +21,9 @@ module Types Contributor = ModelInstance("Contributor") Role = ModelInstance("ControlledVocabularyItem") + + UniqueByKey = Coercible::Symbol.enum(:contributor_id, :item_id, :collection_id, :role_id) + + UniqueByTuple = Array.of(UniqueByKey).constrained(size: 3) end end diff --git a/app/services/contributors/contributions_copier.rb b/app/services/contributors/contributions_copier.rb new file mode 100644 index 00000000..012bd1c5 --- /dev/null +++ b/app/services/contributors/contributions_copier.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Contributors + # Copy all {ItemContribution} and {CollectionContribution} records + # from a source {Contributor} to its merge target (if available). + # + # @see Contributors::CopyContributions + class ContributionsCopier < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :source_contributor, Types::Contributor + end + + standard_execution! + + COPIED = { items: 0, collections: 0 }.freeze + + # @return [{ :items, :collections => Integer }] + attr_reader :copied + + # @return [] + attr_reader :collection_tuples + + # @return [] + attr_reader :item_tuples + + # @return [Contributor, nil] + attr_reader :target_contributor + + delegate :id, to: :target_contributor, prefix: :contributor, allow_nil: true + + # @return [Dry::Monads::Success({ :items, :collections => Integer })] + def call + run_callbacks :execute do + yield prepare! + + yield copy! + end + + Success copied + end + + wrapped_hook! def prepare + @target_contributor = source_contributor.merge_target + + @copied = COPIED.dup + + return Failure[:not_merging] unless source_contributor.merging? + + return Failure[:no_merge_target] unless target_contributor.present? + + @item_tuples = source_contributor.item_contributions.map(&:to_copy_tuple) + + @collection_tuples = source_contributor.collection_contributions.map(&:to_copy_tuple) + + super + end + + wrapped_hook! def copy + @copied[:items] = upsert_tuples!(ItemContribution, item_tuples) + + @copied[:collections] = upsert_tuples!(CollectionContribution, collection_tuples) + + yield target_contributor.recount_contributions + + super + end + + private + + # @param [Class(Contribution)] klass + # @param [] tuples + # @return [Integer] number of contributions copied + def upsert_tuples!(klass, tuples) + # :nocov: + return 0 if tuples.empty? + # :nocov: + + unique_by = klass.contributable_unique_by + + full_tuples = tuples.map { _1.merge(contributor_id:) } + + result = klass.upsert_all(full_tuples, unique_by:, returning: :id) + + result.count + end + end +end diff --git a/app/services/contributors/merge_checker.rb b/app/services/contributors/merge_checker.rb new file mode 100644 index 00000000..2f10bcfc --- /dev/null +++ b/app/services/contributors/merge_checker.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::CheckMerge + class MergeChecker < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :source, Types::Contributor + + param :target, Types::Contributor + end + + standard_execution! + + # @return [:available, :existing, :unknown] + attr_reader :pair_status + + # @return [Dry::Monads::Success(:available)] + # @return [Dry::Monads::Success(:existing)] + # @return [Dry::Monads::Failure(:same_contributor)] + # @return [Dry::Monads::Failure(:source_merging)] + # @return [Dry::Monads::Failure(:target_merging)] + def call + run_callbacks :execute do + yield check! + end + + Success pair_status + end + + wrapped_hook! def check + @pair_status = source.merging_to?(target) ? :existing : :unknown + + return super if @pair_status == :existing + + return Failure[:same_contributor] if source == target + + return Failure[:source_merging] if source.merge_started? + + return Failure[:target_merging] if target.merge_started? + + @pair_status = :available + + super + end + end +end diff --git a/app/services/contributors/merge_failed.rb b/app/services/contributors/merge_failed.rb new file mode 100644 index 00000000..65986e00 --- /dev/null +++ b/app/services/contributors/merge_failed.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Contributors + # An error raised when some part of the merge process failed + # and should be investigated. + class MergeFailed < StandardError; end +end diff --git a/app/services/contributors/merge_invalid.rb b/app/services/contributors/merge_invalid.rb new file mode 100644 index 00000000..c19ed04e --- /dev/null +++ b/app/services/contributors/merge_invalid.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Contributors + # An error raised when the merge process cannot be performed + # owing to some incongruence. + class MergeInvalid < StandardError; end +end diff --git a/app/services/contributors/merge_starter.rb b/app/services/contributors/merge_starter.rb new file mode 100644 index 00000000..844e2b8e --- /dev/null +++ b/app/services/contributors/merge_starter.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::MergeTo + class MergeStarter < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :source, Types::Contributor + + param :target, Types::Contributor + + option :enqueue_merge_job, Types::Bool, default: proc { false } + end + + standard_execution! + + delegate :id, to: :target, prefix: :merge_target + + around_execute :acquire_transaction! + around_execute :acquire_source_lock! + around_execute :acquire_target_lock! + + # @return [Dry::Monads::Success(Contributor, Contributor)] + # @return [Dry::Monads::Failure(:same_contributor)] + # @return [Dry::Monads::Failure(:source_merging)] + # @return [Dry::Monads::Failure(:target_merging)] + def call + run_callbacks :execute do + yield check! + + yield mark_for_merge! + end + + Contributors::MergeJob.perform_later(source, target) if enqueue_merge_job + + Success [source, target] + end + + wrapped_hook! def check + yield source.check_merge_to(target) + + super + end + + wrapped_hook! def mark_for_merge + return super if source.merging_to?(target) + + source.update! merge_target: target, merge_source_status: :merging + + super + end + + private + + # @return [void] + def acquire_transaction! + ActiveRecord::Base.transaction do + ApplicationRecord.with_advisory_lock!("contributors.merge", timeout_seconds: 30, transaction: true, disable_query_cache: true) do + yield + end + end + end + + # @return [void] + def acquire_source_lock! + source.with_lock do + yield + end + end + + # @return [void] + def acquire_target_lock! + target.with_lock do + yield + end + end + end +end diff --git a/app/services/contributors/merger.rb b/app/services/contributors/merger.rb new file mode 100644 index 00000000..f7f70bae --- /dev/null +++ b/app/services/contributors/merger.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Contributors + # An inline merge that will execute {Contributors::MergeJob} in the same process. + # This is intended for manual one-offs and scripts. + # + # @see Contributors::Merge + class Merger < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :source, Types::Contributor + + param :target, Types::Contributor + end + + standard_execution! + + # @return [Dry::Monads::Success(Contributor)] + def call + run_callbacks :execute do + yield verify_merge_lock! + + yield perform_merge! + end + + Success target.reload + end + + wrapped_hook! def verify_merge_lock + yield source.merge_to(target) + + super + end + + wrapped_hook! def perform_merge + Contributors::MergeJob.perform_now(source, target) + rescue Contributors::MergeFailed => e + Failure[:merge_failed, "Merge failed for #{source.id} -> #{target.id}: #{e.message}"] + else + super + end + end +end diff --git a/app/services/contributors/types.rb b/app/services/contributors/types.rb index 595889da..fc8b85e3 100644 --- a/app/services/contributors/types.rb +++ b/app/services/contributors/types.rb @@ -6,6 +6,8 @@ module Types ORCID_FORMAT = %r,\Ahttps://orcid.org/(?\d{4}(?:-\d{4}){3})\z, + Contributor = ModelInstance("Contributor") + ORCID = String.constrained(format: ORCID_FORMAT) Kind = Coercible::Symbol.enum(:organization, :person) @@ -13,7 +15,7 @@ module Types # @see Types::ContributorLookupFieldType LookupField = Symbol.enum(:email, :name, :orcid) - # @see Types::SimpleOrderType + # @see Support::GQL::SimpleOrderType LookupOrder = String.enum("RECENT", "OLDEST").fallback("RECENT").default("RECENT") PresentString = Coercible::String.constrained(rails_present: true) @@ -36,5 +38,9 @@ module Types end.constrained(type: Namae::Name) AnyName = PersonalName | OrganizationName + + User = ModelInstance("User") + + UserLinkage = ApplicationRecord.dry_pg_enum(:contributor_user_linkage, default: "primary").fallback("primary") end end diff --git a/app/services/contributors/user_linker.rb b/app/services/contributors/user_linker.rb new file mode 100644 index 00000000..081d6835 --- /dev/null +++ b/app/services/contributors/user_linker.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Contributors + # @see Contributors::LinkUser + class UserLinker < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :contributor, Types::Contributor + + param :user, Types::User + + option :linkage, Types::UserLinkage, default: proc { "primary" } + end + + standard_execution! + + # @return [ContributorUserLink] + attr_reader :link + + # @return [Dry::Monads::Success(ContributorUserLink)] + def call + run_callbacks :execute do + yield prepare! + + yield persist! + end + + Success link + end + + wrapped_hook! def prepare + @link = ContributorUserLink.where(contributor:).first_or_initialize + + super + end + + wrapped_hook! def persist + @link.assign_attributes(user:, linkage:) + + @link.save! + + super + end + end +end diff --git a/app/services/filtering.rb b/app/services/filtering.rb new file mode 100644 index 00000000..a5d14442 --- /dev/null +++ b/app/services/filtering.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# The top level module for the `Filtering` subsystem. +# +# The primary purpose of this subsystem is to provide +# a declarative interface for building filters on ActiveRecord +# models, and integrating those filters into GraphQL queries. +# +# The main entry point for defining filters is the +# {Filtering::FilterScope} class, which provides a DSL for +# defining filtering arguments and applying them to an +# ActiveRecord scope. +# +# It has a tight integration with the {Resolvers} subsystem, +# allowing filters to be easily added to GraphQL queries by +# providing a scope to {Resolvers::AbstractResolver.filters_with!}. +# +# @see Filtering::FilterScope +module Filtering + # A submodule containing input types for filtering. + module Inputs + end + + # A submodule containing actual {Filtering::FilterScope} implementations. + module Scopes + end +end diff --git a/app/services/filtering/applicator.rb b/app/services/filtering/applicator.rb deleted file mode 100644 index be99ad5e..00000000 --- a/app/services/filtering/applicator.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Filtering - class Applicator - include Dry::Core::Equalizer.new(:filters) - - include Dry::Initializer[undefined: false].define -> do - param :filters, Types::Filters - end - - # @param [ActiveRecord::Relation] top_level_scope - # @return [ActiveRecord::Relation] - def call(top_level_scope) - filtered = top_level_scope.where(top_level_scope.primary_key => filtered_scope) - - filters.apply_ranking_to filtered - end - - # @api private - # @return [ActiveRecord::Relation] - def filtered_scope - @filtered_scope ||= filters.call - end - end -end diff --git a/app/services/filtering/argument_builder.rb b/app/services/filtering/argument_builder.rb deleted file mode 100644 index f02f2b0b..00000000 --- a/app/services/filtering/argument_builder.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Filtering - # @api private - class ArgumentBuilder - include Dry::Initializer[undefined: false].define -> do - param :type, Support::DryGQL::Types::Type - - option :required, Support::DryGQL::Types::Bool.default(false), optional: true, as: :provided_required - end - - def call - @current_type = type - - required! if provided_required - - yield self if block_given? - - return @current_type - end - - def default(value, replace_null: false) - augment_type do |t| - t.meta( - gql_default_value: value, - gql_replace_null: replace_null, - ) unless value.nil? - end - end - - def description(text) - augment_type do |t| - t.gql_description text - end - end - - def required! - augment_type do |t| - t.gql_required true - end - end - - private - - def augment_type - new_type = yield @current_type - - @current_type = Support::DryGQL::Types::Type[new_type] if new_type.present? - - return - end - end -end diff --git a/app/services/filtering/arguments.rb b/app/services/filtering/arguments.rb deleted file mode 100644 index 0489bda0..00000000 --- a/app/services/filtering/arguments.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Filtering - class Arguments - include Dry::Container::Mixin - - # @param [#to_s] key - # @param [#to_s, Class, (Class)] type_key - # @return [Dry::Type] - def add!(key, type_key, **options, &) - type = Filtering::TypeContainer.resolve(type_key) - - configured_type = Filtering::ArgumentBuilder.new(type, **options).call(&) - - register key, configured_type - - return configured_type - end - end -end diff --git a/app/services/filtering/filter_scope.rb b/app/services/filtering/filter_scope.rb index 540dc860..fa5c5f46 100644 --- a/app/services/filtering/filter_scope.rb +++ b/app/services/filtering/filter_scope.rb @@ -1,154 +1,19 @@ # frozen_string_literal: true module Filtering + # The base class for building filter scopes. It has a fluent DSL for defining filtering + # arguments, and methods for applying those filters to an ActiveRecord scope. + # + # Filter scope implementations live in {Filtering::Scopes} and follow a convention of + # being named in a plural of the model they filter, e.g. {Filtering::Scopes::Users} + # serves as the filter scope for the `User` model. When inheriting from this class, + # the model class that is wrapped is provided to {.[]} in order to automatically make + # the necessary connections and definitions. + # # @abstract - class FilterScope < Support::QueryResolver::Base - extend Dry::Initializer + class FilterScope < ::Support::Filtering::DefaultScope + option :current_user, ::Users::Types::Current, default: ::Users::Types::DEFAULT_FROM_REQUEST - define_model_callbacks :ranking, only: %i[before after] - - include HasArguments - - defines :model_klass, type: Support::Models::Types::ModelClass - - model_klass ApplicationRecord - - defines :input_object_name, type: Filtering::Types::String - - input_object_name "" - - defines :input_object_default_value, type: Filtering::Types::Hash.fallback { {} } - - # @see Filtering::Applicator#call - # @param [ActiveRecord::Relation] top_level_scope - # @return [ActiveRecord::Relation] - def apply_to(top_level_scope) - applicator.(top_level_scope) - end - - def initialize_scope - self.class.model_klass.all - end - - def finalize! - augment_scope! do |sc| - sc.reselect(sc.primary_key).reorder(nil) - end - end - - # @param [ActiveRecord::Relation] base - # @return [ActiveRecord::Relation] - def apply_ranking_to(base) - @ranking_scope = base - - run_callbacks :ranking - - return @ranking_scope - ensure - @ranking_scope = nil - end - - # @yieldparam [ActiveRecord::Relation] ranking_scope - # @yieldreturn [ActiveRecord::Relation] - # @return [void] - def augment_ranking! - new_scope = yield @ranking_scope - - @ranking_scope = new_scope unless new_scope.nil? - end - - def filter_inputs - @filter_inputs ||= self.class.arguments.keys.to_h do |key| - [key.to_sym, public_send(key)] - end.compact - end - - private - - def applicator - @applicator ||= Filtering::Applicator.new(self) - end - - class << self - def [](klass) - Class.new(self).tap do |filter_scope| - filter_scope.model_klass klass - - filter_scope.input_object_name "#{klass.model_name}FilterInput" - - filter_scope.extension - - filter_scope.input_object - end - end - - def argument!(...) - super - ensure - @extension = build_extension - @input_object = build_input_object - end - - def extension - @extension ||= build_extension - end - - def input_object - @input_object ||= build_input_object - end - - def options_for_resolver - { - type: input_object, - default: input_object_default_value, - argument_options: { - replace_null_with_default: true, - }, - description: <<~TEXT - Filters that **must** match. - TEXT - } - end - - def options_for_or_resolver - { - type: [input_object, { null: false }], - default: [], - argument_options: { - replace_null_with_default: true, - }, - description: <<~TEXT - An array of filters, at least one of which must match. This is intended more for debugging and introspection in the API, - though a UI could be built. - - **Note**: If `filters` is also specified, at least one set of filters in `orFilters` must match, along with `filters`. - TEXT - } - end - - private - - def build_extension - Class.new(GraphQL::Schema::FieldExtension).tap do |ext| - arguments.each do |key, dry_type| - typing = dry_type.gql_typing - - opts = typing.argument_options - - type = opts.delete :type - - ext.default_argument key, type, **opts - end - end - end - - def build_input_object - Class.new(::Types::FilterInputObject).tap do |io| - io.inherit_from! self - end.tap do |klass| - ::Types::Filtering.accept! klass - end - end - end + def has_admin_access? = current_user.try(:has_admin_access?) end end diff --git a/app/services/filtering/has_arguments.rb b/app/services/filtering/has_arguments.rb deleted file mode 100644 index a517dc0c..00000000 --- a/app/services/filtering/has_arguments.rb +++ /dev/null @@ -1,252 +0,0 @@ -# frozen_string_literal: true - -module Filtering - module HasArguments - extend ActiveSupport::Concern - - included do - extend Dry::Core::ClassAttributes - end - - module ClassMethods - def argument!(key, type, default_value: nil, replace_null: nil, **options) - dry_type = arguments.add! key, type, **options do |arg| - yield arg if block_given? - - arg.default default_value, replace_null: - end - - option key, dry_type, optional: true - end - - def boolean_scope!(key, truthy_scope: key, falsey_scope: nil, **options) - argument! key, :bool, **options do |arg| - yield arg if block_given? - end - - on_true = "scope.#{truthy_scope}" - - on_false = falsey_scope.present? ? "scope.#{falsey_scope}" : "scope.all" - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - augment_scope! do |scope| - case #{key} - when true - #{on_true} - when false - #{on_false} - end - end - end - RUBY - end - - def date_match!(key, column_name: key) - argument! key, :date_match do |arg| - arg.description <<~TEXT - Filter the model's `#{column_name}` with date constraints. - TEXT - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] - - augment_scope! do |scope| - scope.where(#{key}.(attribute)) if #{key}.present? - end - end - RUBY - end - - def float_match!(key, column_name: key) - argument! key, :float_match do |arg| - arg.description <<~TEXT - Filter the model's `#{column_name}` with various float / decimal constraints. - TEXT - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] - - augment_scope! do |scope| - scope.where(#{key}.(attribute)) if #{key}.present? - end - end - RUBY - end - - def fts_search!(search_scope, key: :q) - argument! key, :string do |arg| - arg.description <<~TEXT - Perform a full-text search to approximately match the provided string. - TEXT - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - before_ranking def rank_#{search_scope}! - augment_ranking! do |scope| - scope.#{search_scope}(#{key}).with_pg_search_rank if #{key}.present? - end - end - - after_build def apply_#{search_scope}! - augment_scope! do |scope| - scope.#{search_scope}(#{key}) if #{key}.present? - end - end - RUBY - end - - def identifier_filter! - simple_filter! :identifier, :string do |arg| - arg.description "Look up by the record's unique identifier (exact match)." - end - end - - def integer_match!(key, column_name: key) - argument! key, :integer_match do |arg| - arg.description <<~TEXT - Filter the model's `#{column_name}` with various integer constraints. - TEXT - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] - - augment_scope! do |scope| - scope.where(#{key}.(attribute)) if #{key}.present? - end - end - RUBY - end - - def nested_filter!(association_name, type_name: :"#{association_name}_filters", key: :"#{association_name}_filters", **options) - key = key.to_sym - - argument! key, type_name, **options do |arg| - yield arg if block_given? - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_nested_#{association_name}_filters! - augment_scope! do |scope| - scope.filter_by_nested #{association_name.to_sym.inspect}, #{key} - end - end - RUBY - end - - def simple_filter!(key, type_name, column_name: key, **options) - argument! key, type_name, **options do |arg| - yield arg if block_given? - end - - column_name = column_name.to_sym - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - augment_scope! do |scope| - scope.where(#{column_name.inspect} => #{key}) unless #{key}.nil? || (#{key}.respond_to?(:empty?) && #{key}.empty?) - end - end - RUBY - end - - def simple_scope_filter!(key, type_name, scope_name: :"lookup_by_#{key}", **options) - argument! key, type_name, **options do |arg| - yield arg if block_given? - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - augment_scope! do |scope| - scope.#{scope_name}(#{key}) unless #{key}.blank? - end - end - RUBY - end - - def simple_state_filter!(enum_type, key: :in_state, in_state_scope: :in_state, **options) - argument! key, enum_type, **options do |arg| - yield arg if block_given? - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - augment_scope! do |scope| - scope.#{in_state_scope}(#{key}) if #{key}.present? - end - end - RUBY - end - - def simple_truthy_filter!(key, column_name: key, filter_false: false, **options) - argument! key, :bool, **options do |arg| - yield arg if block_given? - end - - column_name = column_name.to_sym - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_truthy_#{key}! - augment_scope! do |scope| - if #{key} - scope.where(#{column_name.inspect} => true) - elsif #{key} == false && #{filter_false} - scope.where(#{column_name.inspect} => false) - end - end - end - RUBY - end - - def time_match!(key, column_name: key) - argument! key, :time_match do |arg| - arg.description <<~TEXT - Filter the model's `#{column_name}` with time constraints. - TEXT - end - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - after_build def apply_#{key}! - attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] - - augment_scope! do |scope| - scope.where(#{key}.(attribute)) if #{key}.present? - end - end - RUBY - end - - def timestamps! - time_match! :created_at - time_match! :updated_at - end - - # @return [void] - def tracks_mutations! - simple_scope_filter! :user, :users, scope_name: :touched_by_user do |arg| - arg.description "Filter by records that were created OR updated by these users." - end - end - - # @api private - def arguments - @arguments ||= Filtering::Arguments.new - end - - # @api private - def inherited(subclass) - super - - child_args = Filtering::Arguments.new.merge arguments - - subclass.instance_variable_set(:@arguments, child_args) - end - end - end -end diff --git a/app/services/filtering/inputs/comparator_match.rb b/app/services/filtering/inputs/comparator_match.rb deleted file mode 100644 index 2221082e..00000000 --- a/app/services/filtering/inputs/comparator_match.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -module Filtering - module Inputs - # @abstract - class ComparatorMatch < ::Support::FlexibleStruct - include Dry::Core::Memoizable - - BASE_TYPES = Support::DryGQL::TypeContainer.new - - COMPARATORS = %i[eq lt lteq gt gteq not_eq].freeze - - delegate :blank?, to: :comparators - - # @param [Arel::Attribute] attribute - # @return [Arel::Expressions] - def call(attribute) - return if blank? - - expressions = comparators.map do |(cmp, value)| - attribute.public_send(cmp, value) - end - - arel_andify expressions - end - - # @param [Symbol] cmp - def has?(cmp) - comparators.key? cmp - end - - # @api private - # @return [{ Symbol => Object }] - memoize def comparators - attributes.compact - end - - private - - def arel_andify(expressions) - return block_given? ? yield(expressions[0]) : expressions[0] if expressions.one? - - expressions.reduce do |grouping, expression| - expression = yield expression if block_given? - - next grouping if expression.blank? - - if grouping.kind_of?(Arel::Nodes::Grouping) - grouping.expr.and(expression) - else - # First expression - grouping = yield grouping if block_given? - - grouping.and(expression) - end - end - end - - class << self - attr_reader :input_object - - # @param [#to_s] type_key - # @return [Class] - def of(type_key) - type = BASE_TYPES.resolve type_key - - Class.new(self).tap do |klass| - klass.define_comparator_attributes_for! type - end.with_gql_type - end - - protected - - def define_comparator_attributes_for!(type) - COMPARATORS.each do |comparator| - attribute? comparator, type.optional - end - - @input_object = build_input_object_for type - end - - def build_input_object_for(type) - struct_klass = self - - gql_type = type.gql_type - - Class.new(::Types::BaseInputObject).tap do |klass| - klass.graphql_name "#{type.name}FilterMatch" - - klass.description <<~TEXT - Filter a value with various constraints. If no values are provided to any - operator, this filter will be ignored. - - **Note**: The server will _not_ try to check for logical impossibilities, - e.g. `{ lt: 5, gteq: 10 }`: such input will simply not find anything. - TEXT - - COMPARATORS.each do |comparator| - klass.argument comparator, gql_type, required: false - end - - klass.define_method(:prepare) do - struct_klass.new(to_h).presence - end - end - end - - def with_gql_type - gql_type(input_object) - end - end - end - end -end diff --git a/app/services/filtering/inputs/date_match.rb b/app/services/filtering/inputs/date_match.rb deleted file mode 100644 index c5e09dea..00000000 --- a/app/services/filtering/inputs/date_match.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Filtering - module Inputs - class DateMatch < ComparatorMatch.of(:date) - end - end -end diff --git a/app/services/filtering/inputs/float_match.rb b/app/services/filtering/inputs/float_match.rb deleted file mode 100644 index 95e2a199..00000000 --- a/app/services/filtering/inputs/float_match.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Filtering - module Inputs - class FloatMatch < ComparatorMatch.of(:float) - end - end -end diff --git a/app/services/filtering/inputs/integer_match.rb b/app/services/filtering/inputs/integer_match.rb deleted file mode 100644 index c6ae8821..00000000 --- a/app/services/filtering/inputs/integer_match.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Filtering - module Inputs - class IntegerMatch < ComparatorMatch.of(:integer) - end - end -end diff --git a/app/services/filtering/inputs/time_match.rb b/app/services/filtering/inputs/time_match.rb deleted file mode 100644 index 3a90ed9b..00000000 --- a/app/services/filtering/inputs/time_match.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Filtering - module Inputs - class TimeMatch < ComparatorMatch.of(:time) - end - end -end diff --git a/app/services/filtering/runner.rb b/app/services/filtering/runner.rb deleted file mode 100644 index 21d44950..00000000 --- a/app/services/filtering/runner.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Filtering - class Runner - include Dry::Monads[:result] - include Dry::Initializer[undefined: false].define -> do - param :klass, Support::Models::Types::ModelClass - - option :base_scope, Support::Types::Relation, default: proc { klass.all } - option :options, Types::Hash.map(Types::Coercible::Symbol, Types::Any) - option :filter_klass, Types::FiltersClass, default: proc { "::Filtering::Scopes::#{klass.model_name.to_s.pluralize}".constantize } - end - - # @return [Filtering::FilterScope] - attr_reader :filters - - def call - @filters = filter_klass.new(**options) - - result = filters.apply_to base_scope - - Success result - end - end -end diff --git a/app/services/filtering/scopes/contributors.rb b/app/services/filtering/scopes/contributors.rb new file mode 100644 index 00000000..84449c21 --- /dev/null +++ b/app/services/filtering/scopes/contributors.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Filtering + module Scopes + class Contributors < Filtering::FilterScope[Contributor] + has_name_search! + + boolean_scope! :unclaimed do |arg| + arg.description <<~TEXT + Whether to include only contributors that have not been claimed by a user. + TEXT + end + + timestamps! + end + end +end diff --git a/app/services/filtering/scopes/harvest_messages.rb b/app/services/filtering/scopes/harvest_messages.rb index 82c8e0b1..5d35ce1e 100644 --- a/app/services/filtering/scopes/harvest_messages.rb +++ b/app/services/filtering/scopes/harvest_messages.rb @@ -2,11 +2,11 @@ module Filtering module Scopes - class HarvestMessages < Filtering::FilterScope[HarvestMessage] + class HarvestMessages < Filtering::FilterScope[::HarvestMessage] simple_scope_filter! :severity, :harvest_message_level, scope_name: :severity, default_value: "info", - replace_null_with_default: true + replace_null: true end end end diff --git a/app/services/filtering/scopes/items.rb b/app/services/filtering/scopes/items.rb index aa5ecca4..e22b7658 100644 --- a/app/services/filtering/scopes/items.rb +++ b/app/services/filtering/scopes/items.rb @@ -3,13 +3,13 @@ module Filtering module Scopes class Items < Filtering::FilterScope[Item] - boolean_scope! :include_drafts, truthy_scope: :all, falsey_scope: :sans_drafts, default_value: false, replace_null: true do |arg| + boolean_scope! :include_drafts, truthy_scope: :with_drafts, falsey_scope: :sans_drafts, default_value: false, replace_null: true do |arg| arg.description <<~TEXT Whether to include items that are in draft state (i.e. items that are associated with a submission). TEXT - - timestamps! end + + timestamps! end end end diff --git a/app/services/filtering/scopes/submission_target_reviewers.rb b/app/services/filtering/scopes/submission_target_reviewers.rb new file mode 100644 index 00000000..5387188b --- /dev/null +++ b/app/services/filtering/scopes/submission_target_reviewers.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Filtering + module Scopes + # The filtering scope implementation for {SubmissionTargetReviewer}. + class SubmissionTargetReviewers < ::Filtering::FilterScope[::SubmissionTargetReviewer] + simple_scope_filter! :submission_target, :submission_targets do |arg| + arg.description <<~TEXT + Filter by the submission target. + TEXT + end + + simple_scope_filter! :user, :users do |arg| + arg.description <<~TEXT + Filter by the associated user. + TEXT + end + + timestamps! + end + end +end diff --git a/app/services/filtering/type_container.rb b/app/services/filtering/type_container.rb deleted file mode 100644 index a3c8a024..00000000 --- a/app/services/filtering/type_container.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Filtering - TypeContainer = Support::DryGQL::TypeContainer.new.configure do |tc| - tc.add! :any_entity, ::Entities::Types::Entity.gql_loads(::Types::EntityType) - - tc.add! :any_entities, ::Entities::Types::Entities.gql_loads(::Types::EntityType) - - tc.add! :child_entity, ::Entities::Types::Entity.gql_loads(::Types::ChildEntityType) - - tc.add! :date_match, Filtering::Inputs::DateMatch - - tc.add! :float_match, Filtering::Inputs::FloatMatch - - tc.add! :integer_match, Filtering::Inputs::IntegerMatch - - tc.add! :time_match, Filtering::Inputs::TimeMatch - - tc.add_model! ::Collection - tc.add_model! ::Community - tc.add_model! ::Contributor - tc.add_model! ::ControlledVocabulary - tc.add_model! ::DepositorRequest - tc.add_model! ::HarvestSource - tc.add_model! ::Item - tc.add_model! ::Permalink - tc.add_model! ::Role - tc.add_model! ::SchemaDefinition - tc.add_model! ::SchemaVersion - tc.add_model! ::Submission - tc.add_model! ::SubmissionComment - tc.add_model! ::SubmissionReview - tc.add_model! ::SubmissionTarget - tc.add_model! ::SubmissionTargetReviewer - - tc.add_enum! ::Types::DepositorRequestStateType - tc.add_enum! ::Types::HarvestMessageLevelType - tc.add_enum! ::Types::SubmissionCommentRoleType - tc.add_enum! ::Types::SubmissionDepositModeType - tc.add_enum! ::Types::SubmissionReviewStateType - tc.add_enum! ::Types::SubmissionStateType - tc.add_enum! ::Types::SubmissionTargetStateType - end -end diff --git a/app/services/filtering/types.rb b/app/services/filtering/types.rb index 5a565b23..de772a07 100644 --- a/app/services/filtering/types.rb +++ b/app/services/filtering/types.rb @@ -4,12 +4,16 @@ module Filtering module Types extend ::Support::Typespace - Filters = Instance(Filtering::FilterScope) + # A type representing the input hash for filtering arguments. + Input = Hash.fallback { Dry::Core::Constants::EMPTY_HASH } - FiltersClass = Inherits(Filtering::FilterScope) + # A type representing an ActiveRecord scope / relation. + Scope = Support::Types::Relation - Input = Hash.fallback { {}.freeze } + # A type representing the name of a filtering scope, which corresponds to a method on the model's ActiveRecord::Relation. + ScopeName = Symbol - Scope = Instance(ActiveRecord::Relation) + # A list of {ScopeName}s. + ScopeNames = Array.of(ScopeName) end end diff --git a/app/services/global_configurations/current.rb b/app/services/global_configurations/current.rb new file mode 100644 index 00000000..61f69f62 --- /dev/null +++ b/app/services/global_configurations/current.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module GlobalConfigurations + # Current request attributes for GraphQL requests. + class Current < ActiveSupport::CurrentAttributes + attribute :record, default: proc { GlobalConfiguration.fetch } + end +end diff --git a/app/services/harvesting/schedules/types.rb b/app/services/harvesting/schedules/types.rb index 0e97595e..ce2f578f 100644 --- a/app/services/harvesting/schedules/types.rb +++ b/app/services/harvesting/schedules/types.rb @@ -5,8 +5,6 @@ module Schedules module Types extend ::Support::Typespace - include Support::EnhancedTypes - FrequencyExpression = String.optional Mode = ApplicationRecord.dry_pg_enum(:harvest_schedule_mode, default: "manual").fallback("manual") diff --git a/app/services/mappers/types.rb b/app/services/mappers/types.rb index 8f825ddc..3f823311 100644 --- a/app/services/mappers/types.rb +++ b/app/services/mappers/types.rb @@ -4,8 +4,6 @@ module Mappers module Types extend ::Support::Typespace - include Support::EnhancedTypes - DryType = Instance(::Dry::Types::Type) Nulls = Coercible::String.default("last").enum("last", "first").fallback("last") diff --git a/app/services/resolvers/abstract_ordering.rb b/app/services/resolvers/abstract_ordering.rb index ee8a6514..52c264c3 100644 --- a/app/services/resolvers/abstract_ordering.rb +++ b/app/services/resolvers/abstract_ordering.rb @@ -18,7 +18,7 @@ def append_features(base) end included do - defines :order_enum_klass, type: ::Support::DryGQL::Types::EnumClass.optional + defines :order_enum_klass, type: ::Support::DryGQL::Types::EnumType.optional end module BuildsOrderPair diff --git a/app/services/resolvers/abstract_resolver.rb b/app/services/resolvers/abstract_resolver.rb index c446c4bd..a39a6f92 100644 --- a/app/services/resolvers/abstract_resolver.rb +++ b/app/services/resolvers/abstract_resolver.rb @@ -56,7 +56,7 @@ class AbstractResolver < GraphQL::Schema::Resolver # @!scope class # The {Filtering::FilterScope} class to use for filtering. # @return [Class, nil] - defines :filter_scope_klass, type: Resolvers::Types::FilterScopeKlass.optional + defines :filter_scope_klass, type: ::Filtering::FilterScope::Subclass.optional # @!attribute [r] model_klass # @!scope class diff --git a/app/services/resolvers/collection_contribution_resolver.rb b/app/services/resolvers/collection_contribution_resolver.rb index 3aa5d1c3..c4dc658f 100644 --- a/app/services/resolvers/collection_contribution_resolver.rb +++ b/app/services/resolvers/collection_contribution_resolver.rb @@ -12,8 +12,12 @@ class CollectionContributionResolver < AbstractResolver def default_object_association_name if object.kind_of?(Contributor) :collection_contributions + elsif object.kind_of?(Collection) + :contributions else + # :nocov: super + # :nocov: end end end diff --git a/app/services/resolvers/contributor_resolver.rb b/app/services/resolvers/contributor_resolver.rb index dc8ad6ba..7b18c0a7 100644 --- a/app/services/resolvers/contributor_resolver.rb +++ b/app/services/resolvers/contributor_resolver.rb @@ -13,13 +13,19 @@ class ContributorResolver < AbstractResolver resolves_model! ::Contributor + filters_with! Filtering::Scopes::Contributors + option :kind, type: ::Types::ContributorFilterKindType, default: "ALL" PREFIX_DESC = <<~TEXT Search for contributors with names that start with the provided text. TEXT - option :prefix, type: String, description: PREFIX_DESC do |scope, value| + PREFIX_DEPR = <<~TEXT + Use the `nameSearch` filter instead. + TEXT + + option :prefix, type: String, description: PREFIX_DESC, deprecation_reason: PREFIX_DEPR do |scope, value| scope.apply_prefix value end diff --git a/app/services/resolvers/enhancements/page_number_extension.rb b/app/services/resolvers/enhancements/page_number_extension.rb index 1838cabb..4c98b849 100644 --- a/app/services/resolvers/enhancements/page_number_extension.rb +++ b/app/services/resolvers/enhancements/page_number_extension.rb @@ -21,7 +21,7 @@ def apply validates numericality: { allow_blank: true, greater_than_or_equal_to: 1 } end - field.argument :page_direction, ::Types::PageDirectionType, required: false, default_value: :forwards, replace_null_with_default: true do + field.argument :page_direction, ::Support::GQL::PageDirectionType, required: false, default_value: :forwards, replace_null_with_default: true do description "The direction in which pages advance (to traverse pages backwards)" end diff --git a/app/services/resolvers/item_contribution_resolver.rb b/app/services/resolvers/item_contribution_resolver.rb index fdd2a4f1..9685ea48 100644 --- a/app/services/resolvers/item_contribution_resolver.rb +++ b/app/services/resolvers/item_contribution_resolver.rb @@ -12,8 +12,12 @@ class ItemContributionResolver < AbstractResolver def default_object_association_name if object.kind_of?(Contributor) :item_contributions + elsif object.kind_of?(Item) + :contributions else + # :nocov: super + # :nocov: end end end diff --git a/app/services/resolvers/ordered_as_submission_target_reviewer.rb b/app/services/resolvers/ordered_as_submission_target_reviewer.rb new file mode 100644 index 00000000..c3d40164 --- /dev/null +++ b/app/services/resolvers/ordered_as_submission_target_reviewer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Resolvers + # A concern for resolvers that order {SubmissionTargetReviewer} with {::Types::SubmissionTargetReviewerOrderType}. + module OrderedAsSubmissionTargetReviewer + extend ActiveSupport::Concern + + include ::Resolvers::AbstractOrdering + + included do + orders_with! ::Types::SubmissionTargetReviewerOrderType, default: "DEFAULT" + end + end +end diff --git a/app/services/resolvers/simply_ordered.rb b/app/services/resolvers/simply_ordered.rb index ca498bb9..7cc14737 100644 --- a/app/services/resolvers/simply_ordered.rb +++ b/app/services/resolvers/simply_ordered.rb @@ -4,12 +4,12 @@ module Resolvers # For resolvers that don't have their own order ::Types, fall back to a generic that # merely sorts by `created_at` in ascending or descending order. It defaults to `recent`. # - # @see ::Types::SimpleOrderType + # @see ::Support::GQL::SimpleOrderType module SimplyOrdered extend ActiveSupport::Concern included do - orders_with! ::Types::SimpleOrderType, default: "RECENT" + orders_with! ::Support::GQL::SimpleOrderType, default: "RECENT" end end end diff --git a/app/services/resolvers/subitem_resolver.rb b/app/services/resolvers/subitem_resolver.rb index 4d936b94..ab43b2e8 100644 --- a/app/services/resolvers/subitem_resolver.rb +++ b/app/services/resolvers/subitem_resolver.rb @@ -18,6 +18,8 @@ class SubitemResolver < AbstractResolver resolves_model! ::Item, must_have_object: true, association_name: :descendants + filters_with! ::Filtering::Scopes::Items + def resolve_default_scope super.reorder(nil) end diff --git a/app/services/resolvers/submission_target_reviewer_resolver.rb b/app/services/resolvers/submission_target_reviewer_resolver.rb new file mode 100644 index 00000000..e4b1ee28 --- /dev/null +++ b/app/services/resolvers/submission_target_reviewer_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + # A resolver for a {SubmissionTargetReviewer}. + # + # @see SubmissionTargetReviewer + # @see Types::SubmissionTargetReviewerType + class SubmissionTargetReviewerResolver < AbstractResolver + include Resolvers::Enhancements::PageBasedPagination + include Resolvers::OrderedAsSubmissionTargetReviewer + + applies_policy_scope! + + type ::Types::SubmissionTargetReviewerType.connection_type, null: false + + resolves_model! ::SubmissionTargetReviewer + + filters_with! ::Filtering::Scopes::SubmissionTargetReviewers + end +end diff --git a/app/services/resolvers/types.rb b/app/services/resolvers/types.rb index c1818f37..d839091e 100644 --- a/app/services/resolvers/types.rb +++ b/app/services/resolvers/types.rb @@ -5,11 +5,6 @@ module Resolvers module Types extend ::Support::Typespace - # A filter scope for use in GraphQL resolvers. - # - # @see Filtering::FilterScope - FilterScopeKlass = Inherits(::Filtering::FilterScope) - # A reorderer for use in GraphQL resolvers. # # We reorder some params for better display and introspection, diff --git a/app/services/roles/admin_grid.rb b/app/services/roles/admin_permission_grid.rb similarity index 60% rename from app/services/roles/admin_grid.rb rename to app/services/roles/admin_permission_grid.rb index 91c07238..525d5fd6 100644 --- a/app/services/roles/admin_grid.rb +++ b/app/services/roles/admin_permission_grid.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true module Roles - class AdminGrid + # @see Types::AdminPermissionGridType + class AdminPermissionGrid include Roles::Grid permission :access diff --git a/app/services/roles/calculator.rb b/app/services/roles/calculator.rb new file mode 100644 index 00000000..d17f470c --- /dev/null +++ b/app/services/roles/calculator.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Roles + # @see Roles::CalculateSystem + class Calculator < Support::HookBased::Actor + extend Dry::Core::ClassAttributes + + defines :mappings, type: Roles::Types::RoleMappings + + mappings Dry::Core::Constants::EMPTY_HASH + + standard_execution! + + # @return [<{ Symbol => Hash }>] + attr_reader :definitions + + # @return [{ Symbol => Hash }] + attr_reader :roles + + # @return [Dry::Monads::Success<{ Symbol => Hash }>] + def call + run_callbacks :execute do + yield prepare! + + yield define! + + yield finalize! + end + + Success definitions + end + + wrapped_hook! def prepare + @definitions = [] + + @roles = {} + + super + end + + wrapped_hook! def define + mappings.each_value do |mapping| + define_from_mapping!(**mapping) + end + + super + end + + wrapped_hook! def finalize + @definitions = roles.values.freeze + + super + end + + private + + # @param [Symbol] identifier + # @param [Proc] dsl A block to be passed to {Roles::Definer#call}. + # @param [Hash] options + # @option options [String] :name + # @return [void] + def define_from_mapping!(identifier:, dsl:, options: {}) + role!(identifier, **options, &dsl) + end + + def mappings = self.class.mappings + + # @param [Symbol] identifier + # @param [Hash] options + # @option options [String] :name + # @yield [dsl] DSL for defining the role's permissions + # @yieldparam [Roles::Definer] dsl + # @yieldreturn [void] + # @return [void] + def role!(identifier, **options, &) + definer = Roles::Definer.new(identifier, **options) + + roles[definer.identifier] = definer.call(&) + + return + end + + class << self + # @param [Symbol] identifier + # @param [Hash] options + # @option options [String] :name + # @yield [dsl] DSL for defining the role's permissions + # @yieldparam [Roles::Definer] dsl + # @yieldreturn [void] + # @return [void] + def role!(identifier, **options, &dsl) + mapping = { identifier:, options:, dsl:, } + + new_mappings = mappings.merge(identifier => mapping).freeze + + mappings new_mappings + end + end + end +end diff --git a/app/services/roles/composes_grids.rb b/app/services/roles/composes_grids.rb index 892933ef..dbfbf30c 100644 --- a/app/services/roles/composes_grids.rb +++ b/app/services/roles/composes_grids.rb @@ -7,7 +7,7 @@ module ComposesGrids included do extend Dry::Core::ClassAttributes - include StoreModel::Model + include Support::EnhancedStoreModel # @!scope class # @!attribute [r] permission_grids @@ -71,12 +71,6 @@ def compose_scope(scope, name) end module ClassMethods - # @see #build_with - # @return [Roles::ComposesGrids] - def allow_everything - build_with true - end - # @!scope class # @!attribute [r] available_actions # @return [] diff --git a/app/services/roles/contributor_permission_grid.rb b/app/services/roles/contributor_permission_grid.rb new file mode 100644 index 00000000..869009ce --- /dev/null +++ b/app/services/roles/contributor_permission_grid.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Roles + # @see Types::ContributorPermissionGridType + class ContributorPermissionGrid < PermissionGrid + permission :claim, :merge + end +end diff --git a/app/services/roles/definer.rb b/app/services/roles/definer.rb index 78d3ec65..1d33690c 100644 --- a/app/services/roles/definer.rb +++ b/app/services/roles/definer.rb @@ -2,6 +2,9 @@ module Roles # Define a role with a specific list of permissions for scoped and global access. + # + # @api private + # @see Roles::Calculator class Definer include Dry::Initializer[undefined: false].define -> do param :identifier, Dry::Types["coercible.string"] diff --git a/app/services/roles/global_access_control_list.rb b/app/services/roles/global_access_control_list.rb index 579f1aa9..a002c294 100644 --- a/app/services/roles/global_access_control_list.rb +++ b/app/services/roles/global_access_control_list.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true module Roles + # @see Types::GlobalAccessControlListType class GlobalAccessControlList include Roles::ComposesGrids - grid :admin, AdminGrid, default: false - grid :communities, EntityPermissionGrid, default: false - grid :contributors, default: false - grid :roles, default: { read: true } + grid :admin, Roles::AdminPermissionGrid, default: false + grid :communities, Roles::EntityPermissionGrid, default: false + grid :contributors, Roles::ContributorPermissionGrid, default: false + grid :roles, Roles::RolePermissionGrid, default: { read: true } grid :settings, Roles::SettingsGrid, default: false - grid :users, default: false + grid :users, Roles::UserPermissionGrid, default: false end end diff --git a/app/services/roles/mapper.rb b/app/services/roles/mapper.rb deleted file mode 100644 index 9930272f..00000000 --- a/app/services/roles/mapper.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Roles - class Mapper - def call - yield self - - return roles.values - end - - def role!(identifier, **options, &) - definer = Roles::Definer.new(identifier, **options) - - roles[definer.identifier] = definer.call(&) - - return nil - end - - private - - def roles - @roles ||= {} - end - end -end diff --git a/app/services/roles/role_permission_grid.rb b/app/services/roles/role_permission_grid.rb new file mode 100644 index 00000000..db631f48 --- /dev/null +++ b/app/services/roles/role_permission_grid.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Roles + # @see Types::RolePermissionGridType + class RolePermissionGrid < PermissionGrid + end +end diff --git a/app/services/roles/settings_grid.rb b/app/services/roles/settings_grid.rb index 210a0d0b..4b533ec5 100644 --- a/app/services/roles/settings_grid.rb +++ b/app/services/roles/settings_grid.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module Roles + # @see Types::SettingsPermissionGridType class SettingsGrid include Roles::Grid diff --git a/app/services/roles/system_calculator.rb b/app/services/roles/system_calculator.rb new file mode 100644 index 00000000..7f2831b3 --- /dev/null +++ b/app/services/roles/system_calculator.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Roles + # @see Roles::CalculateSystem + class SystemCalculator < Roles::Calculator + # Admins can do anything in the system. + # This role is not directly assignable, but is instead managed + # by Keycloak. + role! :admin do |r| + r.acl ?* + + r.gacl ?* + end + + role! :manager do |r| + # A manager can do anything under its assigned hierarchy + r.acl ?* do |acl| + # Except delete the thing it is assigned to. + acl.deny! "self.delete" + end + + r.gacl "admin.access", "contributors.*", "roles.read", "users.read" + end + + role! :editor do |r| + r.acl do |acl| + # An editor can read anything under its assigned hierarchy + acl.allow! "*.read", "*.assets.read" + # An editor can update any assigned entity as well as its subcollections and items + acl.allow! "self.update", "collections.update", "items.update" + # An editor can update any asset + acl.allow! "*.assets.update" + end + + r.gacl "admin.access", "contributors.read", "contributors.create", "contributors.update", "contributors.claim", "contributors.merge" do |gacl| + gacl.deny! "contributors.delete" + + gacl.allow! "roles.read" + end + end + + role! :reviewer do |r| + r.acl do |acl| + # A reviewer can read anything under its assigned hierarchy + acl.allow! "*.read" + + # A reviewer can review any assigned entity as well as its subcollections and items + acl.allow! "self.review", "collections.review", "items.review" + + # A reviewer can read any assets under its assigned hierarchy + acl.allow! "*.assets.read" + end + + r.gacl "admin.access", "contributors.read", "contributors.claim", "roles.read" + end + + role! :depositor do |r| + r.acl do |acl| + # A depositor can read anything under its assigned hierarchy + acl.allow! "*.read" + + # A depositor can deposit to any assigned entity as well as its subcollections and items + acl.allow! "self.deposit", "collections.deposit", "items.deposit" + + # A depositor can read any assets under its assigned hierarchy + acl.allow! "*.assets.read" + end + + r.gacl "admin.access", "contributors.read", "contributors.claim", "roles.read" + end + + role! :author do |r| + r.acl do |acl| + # An author can update and read its own entity + acl.allow! "self.update", "self.read", "self.assets.*" + end + + r.gacl "admin.access", "contributors.read" + end + + role! :reader do |r| + r.acl "*.read", "*.assets.read" + + r.gacl "contributors.read", "roles.read" + end + end +end diff --git a/app/services/roles/types.rb b/app/services/roles/types.rb index bf70537b..7960b499 100644 --- a/app/services/roles/types.rb +++ b/app/services/roles/types.rb @@ -26,6 +26,8 @@ module Types GACL = GRID[Roles::GlobalAccessControlList] + Callable = Interface(:call) + PermissionName = String.constrained(format: PERMISSION_FORMAT) PermissionList = Array.of(PermissionName) @@ -42,6 +44,18 @@ module Types PolicyPredicate = Symbol + RoleIdentifier = Coercible::Symbol + + RoleOptions = Hash.schema(name?: String) + + RoleMapping = Hash.schema( + identifier: RoleIdentifier, + options: RoleOptions, + dsl: Callable + ) + + RoleMappings = Hash.map(RoleIdentifier, RoleMapping) + EffectivePermissionMap = Hash.map(PermissionName, PolicyPredicate) end end diff --git a/app/services/roles/user_permission_grid.rb b/app/services/roles/user_permission_grid.rb new file mode 100644 index 00000000..307eef24 --- /dev/null +++ b/app/services/roles/user_permission_grid.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Roles + # @see Types::UserPermissionGridType + class UserPermissionGrid < PermissionGrid + end +end diff --git a/app/services/settings/contributors.rb b/app/services/settings/contributors.rb new file mode 100644 index 00000000..0b8b0e16 --- /dev/null +++ b/app/services/settings/contributors.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Settings + # Settings for depositing to this installation. + # @see GlobalConfiguration + # @see ::Types::Settings::ContributorsSettingsInputType + # @see ::Types::Settings::ContributorsSettingsType + class Contributors + include Support::EnhancedStoreModel + + strip_attributes allow_empty: true, collapse_spaces: true + + attribute :claimable, :boolean, default: MeruConfig.contributor_claimable + + attribute :owner_updatable, :boolean, default: MeruConfig.contributor_owner_updatable + end +end diff --git a/app/services/settings/depositing.rb b/app/services/settings/depositing.rb index bc99e2a3..49a92b85 100644 --- a/app/services/settings/depositing.rb +++ b/app/services/settings/depositing.rb @@ -8,7 +8,7 @@ module Settings class Depositing include Support::EnhancedStoreModel - strip_attributes collapse_spaces: true + strip_attributes allow_empty: true, collapse_spaces: true attribute :agreement, :string, default: "" @@ -16,6 +16,8 @@ class Depositing validates :agreement, enforced_string: true + def missing_agreement? = agreement.blank? + # @api private # @return [void] def reset! diff --git a/app/services/submission_targets/configurator.rb b/app/services/submission_targets/configurator.rb index 69e47792..86af5a1d 100644 --- a/app/services/submission_targets/configurator.rb +++ b/app/services/submission_targets/configurator.rb @@ -6,26 +6,46 @@ class Configurator < Support::HookBased::Actor include Dry::Initializer[undefined: false].define -> do param :configurable, SubmissionTargets::Types::Configurable - option :deposit_mode, SubmissionTargets::Types::DepositMode, default: -> { "direct" } + option :entity, SubmissionTargets::Types::Entity, default: -> do + case configurable + in HierarchicalEntity then configurable + in SubmissionTarget then configurable.entity + else + # :nocov: + raise "Unexpected configurable type: #{configurable.class}" + # :nocov: + end + end - option :deposit_targets, SubmissionTargets::Types::DepositTargets, default: -> { [] } + option :submission_target, SubmissionTargets::Types::SubmissionTarget, default: -> do + case configurable + in SubmissionTarget + configurable + in HierarchicalEntity + configurable.fetch_submission_target! + else + # :nocov: + raise "Unexpected configurable type: #{configurable.class}" + # :nocov: + end + end - option :schema_versions, SubmissionTargets::Types::SchemaVersions, default: -> { [] } + option :deposit_mode, SubmissionTargets::Types::DepositMode, default: -> { submission_target.deposit_mode } - option :agreement_content, Types::String, optional: true + option :deposit_targets, SubmissionTargets::Types::DepositTargets, default: -> { submission_target.deposit_targets } - option :agreement_required, Types::Params::Bool, default: -> { false } + option :schema_versions, SubmissionTargets::Types::SchemaVersions, default: -> { submission_target.schema_versions } - option :description, Types::Hash, default: -> { {} } - end + option :agreement_content, Types::String, default: proc { submission_target.agreement_content } - standard_execution! + option :agreement_required, Types::Params::Bool, default: -> { submission_target.agreement_required } + + option :auto_approve_depositors, Types::Params::Bool, default: -> { submission_target.auto_approve_depositors } - # @return [HierarchicalEntity] - attr_reader :entity + option :description, Types::Hash, default: -> { submission_target.description.as_json || Dry::Core::Constants::EMPTY_HASH } + end - # @return [SubmissionTarget] - attr_reader :submission_target + standard_execution! # @return [Dry::Monads::Success(SubmissionTarget)] def call @@ -39,16 +59,15 @@ def call end wrapped_hook! def prepare - @entity, @submission_target = extract_configurable - - @attrs = { + attrs = { deposit_mode:, agreement_content:, agreement_required:, + auto_approve_depositors:, description:, } - @submission_target.assign_attributes(@attrs) + submission_target.assign_attributes(attrs) super end @@ -68,17 +87,5 @@ def call super end - - private - - # @return [(HierarchicalEntity, SubmissionTarget)] - def extract_configurable - case configurable - in SubmissionTarget => submission_target - [submission_target.entity, submission_target] - else - [configurable, configurable.fetch_submission_target!] - end - end end end diff --git a/app/services/submissions/author_enforcer.rb b/app/services/submissions/author_enforcer.rb new file mode 100644 index 00000000..65d8222e --- /dev/null +++ b/app/services/submissions/author_enforcer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Submissions + # @see Submissions::EnforceAuthor + class AuthorEnforcer < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :submission, Submissions::Types::Submission + end + + standard_execution! + + # @return [Role] + attr_reader :author_role + + # @return [Dry::Monads::Success(void)] + def call + run_callbacks :execute do + yield prepare! + + yield grant_role! + end + + Success() + end + + wrapped_hook! def prepare + @author_role = Role.fetch(:author) + + super + end + + wrapped_hook! def grant_role + yield MeruAPI::Container["access.grant"].(author_role, on: submission.entity, to: submission.user) + + super + end + end +end diff --git a/app/services/submissions/contributions_attacher.rb b/app/services/submissions/contributions_attacher.rb new file mode 100644 index 00000000..e258ad57 --- /dev/null +++ b/app/services/submissions/contributions_attacher.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Submissions + # Attach the default contribution(s) for a submission. + # + # Presently, this will only attach an author contribution for the submission's associated {User}, + # but in the future we may allow other contributions to be attached to the {SubmissionTarget}, + # or via other logic. + # + # @see Submissions::AttachContributions + class ContributionsAttacher < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :submission, Types::Submission + end + + standard_execution! + + delegate :entity, :user, to: :submission, prefix: :submission + + alias submitter submission_user + + # @return [Contributor] + attr_reader :author + + # @return [ControlledVocabularyItem] + attr_reader :author_role + + # @return [ControlledVocabulary] + attr_reader :controlled_vocabulary + + # @return [ControlledVocabularyItem] + attr_reader :default_role + + # @return [ContributionRoleConfiguration] + attr_reader :system_configuration + + # @return [Dry::Monads::Success(void)] + def call + run_callbacks :execute do + yield prepare! + + yield persist! + end + + Success() + end + + wrapped_hook! def prepare + @author = yield submitter.fetch_author + + load_role_config! + + super + end + + wrapped_hook! def persist + yield submission_entity.attach_contribution(author, role: author_role) + + super + end + + private + + def load_role_config! + @system_configuration = GlobalConfiguration.fetch.contribution_role_configuration + + @controlled_vocabulary = @system_configuration.controlled_vocabulary + + load_default_role! + + load_author_role! + end + + # @return [void] + def load_author_role! + @author_role = find_candidate_role_within! do |y| + y << controlled_vocabulary.first_tagged_with("author") + y << default_role + end + end + + # @return [void] + def load_default_role! + @default_role = find_candidate_role_within! do |y| + y << controlled_vocabulary.first_tagged_with("default") + y << system_configuration.default_item + end + end + + def find_candidate_role_within! + candidates = Enumerator.new do |y| + yield y + end.lazy + + candidates.detect(&:present?) or raise "No candidate found" + end + end +end diff --git a/app/services/submissions/draft_entity_factory.rb b/app/services/submissions/draft_entity_factory.rb index 81504a16..61d69a32 100644 --- a/app/services/submissions/draft_entity_factory.rb +++ b/app/services/submissions/draft_entity_factory.rb @@ -25,6 +25,8 @@ def call yield prepare! yield persist! + + yield attach_contributions! end Success draft @@ -55,5 +57,13 @@ def call super end + + wrapped_hook! def attach_contributions + yield submission.enforce_author + + yield submission.attach_contributions + + super + end end end diff --git a/app/services/submissions/publisher.rb b/app/services/submissions/publisher.rb index 327a3d0f..8c7268da 100644 --- a/app/services/submissions/publisher.rb +++ b/app/services/submissions/publisher.rb @@ -16,6 +16,9 @@ class Publisher < Support::HookBased::Actor delegate :entity, to: :submission + # @return [Role] + attr_reader :author_role + # @return [SubmissionPublication] attr_reader :submission_publication @@ -33,6 +36,8 @@ def call wrapped_hook! def prepare @submission_publication = provided_publication || submission.submission_publications.find_or_create_by!(user:) + @author_role = Role.fetch(:author) + super end @@ -41,13 +46,15 @@ def call handle_state_transitions! + remove_author_role! + super end around_try_to_publish :wrap_in_transaction! wrapped_hook! def publish_entity - try_to_publish! + yield try_to_publish! super rescue StandardError => e @@ -76,6 +83,11 @@ def handle_state_transitions! submission.transition_to!(:published) end + # @return [void] + def remove_author_role! + MeruAPI::Container["access.revoke"].(author_role, on: entity, to: submission.user) + end + def wrap_in_transaction! ActiveRecord::Base.transaction do yield diff --git a/app/services/users/author_fetcher.rb b/app/services/users/author_fetcher.rb new file mode 100644 index 00000000..54908c1a --- /dev/null +++ b/app/services/users/author_fetcher.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Users + # @see Users::FetchAuthor + class AuthorFetcher < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :user, Users::Types::User + end + + standard_execution! + + # @return [Contributor] + attr_reader :author + + # @return [Dry::Monads::Success(Contributor)] + def call + run_callbacks :execute do + yield prepare! + + yield maybe_create_default_author! + end + + Success(author) + end + + wrapped_hook! def prepare + @author = user.primary_contributor + + super + end + + wrapped_hook! def maybe_create_default_author + return super if author.present? + + @author = yield user.create_default_author + + super + end + end +end diff --git a/app/services/users/default_author_creator.rb b/app/services/users/default_author_creator.rb new file mode 100644 index 00000000..a3b5491a --- /dev/null +++ b/app/services/users/default_author_creator.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Users + # @see Users::CreateDefaultAuthor + class DefaultAuthorCreator < Support::HookBased::Actor + include Dry::Initializer[undefined: false].define -> do + param :user, Users::Types::User + end + + standard_execution! + + # @return [Contributor] + attr_reader :contributor + + # @return [String] + attr_reader :identifier + + # @return [ContributorUserLink] + attr_reader :user_link + + # @return [Dry::Monads::Success(Contributor)] + def call + run_callbacks :execute do + yield prepare! + + yield persist! + end + + Success user.reload_primary_contributor + end + + wrapped_hook! def prepare + @identifier = user.keycloak_id + + @contributor = Contributor.where(identifier:).first_or_initialize + + @contributor.kind = :person + + @contributor.identifier = user.keycloak_id + + @contributor.properties = build_properties + + @user_link = nil + + super + end + + wrapped_hook! def persist + contributor.save! + + @user_link = yield contributor.link_user(user) + + super + end + + private + + # @return [Contributors::Properties] + def build_properties + person = build_person_properties + + Contributors::Properties.new(person:) + end + + # @return [Contributors::PersonProperties] + def build_person_properties + Contributors::PersonProperties.new( + given_name: user.given_name, + family_name: user.family_name, + ) + end + end +end diff --git a/app/services/users/types.rb b/app/services/users/types.rb index 79a5cb77..f0d6824a 100644 --- a/app/services/users/types.rb +++ b/app/services/users/types.rb @@ -5,33 +5,13 @@ module Users # # @see ::AnonymousUser # @see ::User + # @see ::Support::Users::Types module Types - extend ::Support::Typespace + extend Support::Typespace - AccessManagement = ApplicationRecord.dry_pg_enum("access_management", default: "forbidden").fallback("forbidden") - - # A type matching a {User} - Authenticated = Instance(::User) - - # A type matching an {AnonymousUser} - Anonymous = Instance(::AnonymousUser) + include Support::Users::Types - # A proc that returns a default {AnonymousUser} - DEFAULT = proc { AnonymousUser.new } - - # This is a type that will ensure a {User} is populated, and if not provided, - # nil, or otherwise invalid, it will fall back to an {AnonymousUser}. - Current = (Authenticated | Anonymous).fallback do - ::AnonymousUser.new - end.default(&DEFAULT) - - # An enum switching on the state of a user's authentication. - State = Symbol.enum(:anonymous, :authenticated).constructor do |value| - case value - when Authenticated then :authenticated - else - :anonymous - end - end + # A type representing an access management enum. + AccessManagement = ApplicationRecord.dry_pg_enum("access_management", default: "forbidden").fallback("forbidden") end end diff --git a/config/configs/meru_config.rb b/config/configs/meru_config.rb index a19ae800..2c370b7c 100644 --- a/config/configs/meru_config.rb +++ b/config/configs/meru_config.rb @@ -3,13 +3,17 @@ class MeruConfig < ApplicationConfig attr_config tenant_id: "meru", tenant_name: "Meru", include_testing_schemas: false, serialize_rendering: false, experimental_dataloader: false, pool_size: 20, log_slow_fields: false, validate_graphql_query: true, - disable_layout_preloading: false, disable_record_preloading: false + disable_layout_preloading: false, disable_record_preloading: false, + auto_approve_depositors: false, + contributor_claimable: true, + contributor_owner_updatable: true attr_config :new_relic_license_key coerce_types experimental_dataloader: :boolean, include_testing_schemas: :boolean, serialize_rendering: :boolean, pool_size: :integer, log_slow_fields: :boolean, validate_graphql_query: :boolean, disable_layout_preloading: :boolean, - disable_record_preloading: :boolean + disable_record_preloading: :boolean, auto_approve_depositors: :boolean, + contributor_claimable: :boolean, contributor_owner_updatable: :boolean def record_preloading_enabled? = !disable_record_preloading? diff --git a/config/initializers/960_gql.rb b/config/initializers/960_gql.rb index 244e7201..4f8218f4 100644 --- a/config/initializers/960_gql.rb +++ b/config/initializers/960_gql.rb @@ -6,6 +6,8 @@ ActiveSupport::Notifications.subscribe(/graphql/) do |event| # :nocov: + next unless event.respond_to?(:duration) && event.duration.present? + name = event.name duration = event.duration.round(2) diff --git a/config/locales/en.yml b/config/locales/en.yml index 330b4936..e20cade1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -92,6 +92,10 @@ en: unacceptable_edge: "An entity of type '%{parent}' cannot be a direct parent of '%{child}'" update_and_clear_attachment: "cannot receive a new attachment while also clearing it" rules: + contributor_merge_in_progress: "A contributor merge is already in progress for these two." + contributor_merge_same_contributor: "A contributor cannot be merged with itself." + contributor_merge_source_merging: "The source contributor is already being merged into another contributor." + contributor_merge_target_merging: "The target contributor is already being merged into another contributor." depositor_agreement_required: "You must accept the depositor agreement to submit." not_yet_implemented: "This mutation is not yet implemented." revalidation_connection_failed: "The revalidation request failed to connect." diff --git a/config/meru.yml b/config/meru.yml index 9766decf..882756a2 100644 --- a/config/meru.yml +++ b/config/meru.yml @@ -1,4 +1,5 @@ default: &default + auto_approve_depositors: true include_development_schemas: false include_testing_schemas: false tenant_id: "meru" diff --git a/db/migrate/20260509002542_add_author_role.rb b/db/migrate/20260509002542_add_author_role.rb new file mode 100644 index 00000000..dfbbf36c --- /dev/null +++ b/db/migrate/20260509002542_add_author_role.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddAuthorRole < ActiveRecord::Migration[8.1] + def up + execute <<~SQL + ALTER TYPE role_identifier ADD VALUE IF NOT EXISTS 'author' BEFORE 'reader'; + SQL + end + + def down + # intentionally left blank + end +end diff --git a/db/migrate/20260509002726_update_roles_for_author.rb b/db/migrate/20260509002726_update_roles_for_author.rb new file mode 100644 index 00000000..b4062f38 --- /dev/null +++ b/db/migrate/20260509002726_update_roles_for_author.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class UpdateRolesForAuthor < ActiveRecord::Migration[8.1] + def up + execute <<~SQL + CREATE OR REPLACE FUNCTION public.calculate_role_priority(public.role_identifier) RETURNS int AS $$ + SELECT CASE $1 + WHEN 'admin' THEN 40000 + WHEN 'manager' THEN 20000 + WHEN 'editor' THEN -20000 + WHEN 'reviewer' THEN -30000 + WHEN 'depositor' THEN -35000 + WHEN 'author' THEN -37500 + WHEN 'reader' THEN -40000 + END; + $$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE; + SQL + end + + def down + # Intentionally left blank + end +end diff --git a/db/migrate/20260509003048_allow_contributors_to_be_merged.rb b/db/migrate/20260509003048_allow_contributors_to_be_merged.rb new file mode 100644 index 00000000..126cee2c --- /dev/null +++ b/db/migrate/20260509003048_allow_contributors_to_be_merged.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class AllowContributorsToBeMerged < ActiveRecord::Migration[8.1] + def change + create_enum :contributor_merge_source_status, %w[unmerged merging merged] + create_enum :contributor_merge_target_status, %w[inactive active] + + change_table :contributors do |t| + t.references :merge_target, foreign_key: { to_table: :contributors, on_delete: :nullify }, null: true, type: :uuid + + t.enum :merge_source_status, enum_type: "contributor_merge_source_status", null: false, default: "unmerged" + t.enum :merge_target_status, enum_type: "contributor_merge_target_status", null: false, default: "inactive" + + t.check_constraint <<~SQL, name: "merge_target_cannot_be_self" + merge_target_id IS NULL OR merge_target_id <> id + SQL + end + + change_table :global_configurations do |t| + t.jsonb :contributors, null: false, default: {} + end + + change_table :harvest_contributors do |t| + t.boolean :merged, null: false, default: false + end + + reversible do |dir| + dir.up do + execute <<~SQL + UPDATE global_configurations SET contributors = jsonb_build_object( + 'claimable', true, + 'owner_updatable', true + ) + SQL + end + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 1991d06d..09895cbb 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -255,6 +255,27 @@ CREATE TYPE public.contributor_list_filter AS ENUM ( ); +-- +-- Name: contributor_merge_source_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.contributor_merge_source_status AS ENUM ( + 'unmerged', + 'merging', + 'merged' +); + + +-- +-- Name: contributor_merge_target_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.contributor_merge_target_status AS ENUM ( + 'inactive', + 'active' +); + + -- -- Name: contributor_user_linkage; Type: TYPE; Schema: public; Owner: - -- @@ -784,6 +805,7 @@ CREATE TYPE public.role_identifier AS ENUM ( 'editor', 'reviewer', 'depositor', + 'author', 'reader' ); @@ -1285,6 +1307,7 @@ WHEN 'manager' THEN 20000 WHEN 'editor' THEN -20000 WHEN 'reviewer' THEN -30000 WHEN 'depositor' THEN -35000 +WHEN 'author' THEN -37500 WHEN 'reader' THEN -40000 END; $_$; @@ -4800,7 +4823,11 @@ CREATE TABLE public.contributors ( affiliation text GENERATED ALWAYS AS (public.derive_contributor_affiliation(kind, properties)) STORED, orcid public.citext, search_name text GENERATED ALWAYS AS (public.to_prefix_search(public.derive_contributor_name(kind, properties))) STORED, - harvest_modification_status public.harvest_modification_status DEFAULT 'unharvested'::public.harvest_modification_status NOT NULL + harvest_modification_status public.harvest_modification_status DEFAULT 'unharvested'::public.harvest_modification_status NOT NULL, + merge_target_id uuid, + merge_source_status public.contributor_merge_source_status DEFAULT 'unmerged'::public.contributor_merge_source_status NOT NULL, + merge_target_status public.contributor_merge_target_status DEFAULT 'inactive'::public.contributor_merge_target_status NOT NULL, + CONSTRAINT merge_target_cannot_be_self CHECK (((merge_target_id IS NULL) OR (merge_target_id <> id))) ); @@ -5529,6 +5556,7 @@ CREATE TABLE public.global_configurations ( logo_data jsonb, banner_data jsonb, depositing jsonb DEFAULT '{}'::jsonb NOT NULL, + contributors jsonb DEFAULT '{}'::jsonb NOT NULL, CONSTRAINT ensure_global_configurations_singleton CHECK (guard) ); @@ -5926,7 +5954,8 @@ CREATE TABLE public.harvest_contributors ( updated_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, tracked_attributes text[] DEFAULT '{}'::text[] NOT NULL, tracked_properties text[] DEFAULT '{}'::text[] NOT NULL, - orcid text + orcid text, + merged boolean DEFAULT false NOT NULL ); @@ -11323,6 +11352,13 @@ CREATE INDEX index_contributors_on_contribution_count ON public.contributors USI CREATE UNIQUE INDEX index_contributors_on_identifier ON public.contributors USING btree (identifier); +-- +-- Name: index_contributors_on_merge_target_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_contributors_on_merge_target_id ON public.contributors USING btree (merge_target_id); + + -- -- Name: index_contributors_on_name; Type: INDEX; Schema: public; Owner: - -- @@ -15135,6 +15171,14 @@ ALTER TABLE ONLY public.templates_descendant_list_definitions ADD CONSTRAINT fk_rails_2293d53f73 FOREIGN KEY (layout_definition_id) REFERENCES public.layouts_main_definitions(id) ON DELETE CASCADE; +-- +-- Name: contributors fk_rails_22a32e0ffc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contributors + ADD CONSTRAINT fk_rails_22a32e0ffc FOREIGN KEY (merge_target_id) REFERENCES public.contributors(id) ON DELETE SET NULL; + + -- -- Name: harvest_attempt_entity_link_transitions fk_rails_248d27a3f5; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -16830,6 +16874,9 @@ ALTER TABLE ONLY public.templates_ordering_instances SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260509003048'), +('20260509002726'), +('20260509002542'), ('20260422183557'), ('20260421173838'), ('20260316200244'), diff --git a/docker/test.env b/docker/test.env index 39942e9b..e1ea1184 100644 --- a/docker/test.env +++ b/docker/test.env @@ -1,7 +1,7 @@ RACK_ENV=test RAILS_ENV=test REDIS_URL=redis://test-redis:6379 -RD_PROF=1 -RD_PROF_TOP=10 -RD_PROF_LET_TOP=10 -TEST_MEM_PROF='alloc' +# RD_PROF=1 +# RD_PROF_TOP=10 +# RD_PROF_LET_TOP=10 +# TEST_MEM_PROF='alloc' diff --git a/lib/frozen_record/permission_definitions.yml b/lib/frozen_record/permission_definitions.yml index 2477ce0d..aa311388 100644 --- a/lib/frozen_record/permission_definitions.yml +++ b/lib/frozen_record/permission_definitions.yml @@ -90,6 +90,10 @@ kind: global - path: contributors.delete kind: global +- path: contributors.claim + kind: global +- path: contributors.merge + kind: global - path: roles.read kind: global - path: roles.create diff --git a/lib/frozen_record/system_roles.yml b/lib/frozen_record/system_roles.yml index 0ac40408..34602277 100644 --- a/lib/frozen_record/system_roles.yml +++ b/lib/frozen_record/system_roles.yml @@ -60,6 +60,8 @@ :create: true :update: true :delete: true + :claim: true + :merge: true :roles: :read: true :create: true @@ -122,6 +124,8 @@ :create: true :update: true :delete: true + :claim: true + :merge: true :roles: :read: true :users: @@ -154,6 +158,8 @@ :read: true :create: true :update: true + :claim: true + :merge: true :delete: false :roles: :read: true @@ -180,6 +186,7 @@ :access: true :contributors: :read: true + :claim: true :roles: :read: true identifier: reviewer @@ -205,10 +212,27 @@ :access: true :contributors: :read: true + :claim: true :roles: :read: true identifier: depositor name: Depositor +- access_control_list: + :self: + :update: true + :read: true + :assets: + :read: true + :create: true + :update: true + :delete: true + global_access_control_list: + :admin: + :access: true + :contributors: + :read: true + identifier: author + name: Author - access_control_list: :self: :read: true diff --git a/lib/generators/layout/templates/gql/definition.rb.tt b/lib/generators/layout/templates/gql/definition.rb.tt index ee149526..982ad6a7 100644 --- a/lib/generators/layout/templates/gql/definition.rb.tt +++ b/lib/generators/layout/templates/gql/definition.rb.tt @@ -6,7 +6,7 @@ module Types module Layouts # @see ::Layouts::<%= class_name %>Definition - class <%= class_name %>LayoutDefinitionType < AbstractModel + class <%= class_name %>LayoutDefinitionType < ::Types::BaseModel implements ::Types::LayoutDefinitionType field :templates, [Types::Any<%= class_name %>TemplateDefinitionType, { null: false }], null: false do diff --git a/lib/generators/layout/templates/gql/instance.rb.tt b/lib/generators/layout/templates/gql/instance.rb.tt index 1e39be0e..10425da5 100644 --- a/lib/generators/layout/templates/gql/instance.rb.tt +++ b/lib/generators/layout/templates/gql/instance.rb.tt @@ -6,7 +6,7 @@ module Types module Layouts # @see ::Layouts::<%= class_name %>Instance - class <%= class_name %>LayoutInstanceType < AbstractModel + class <%= class_name %>LayoutInstanceType < ::Types::BaseModel implements ::Types::LayoutInstanceType field :layout_definition, Types::Layouts::<%= class_name %>LayoutDefinitionType, null: false do diff --git a/lib/generators/model_interface/templates/query_interface.rb.tt b/lib/generators/model_interface/templates/query_interface.rb.tt index ddde020d..7a5518d0 100644 --- a/lib/generators/model_interface/templates/query_interface.rb.tt +++ b/lib/generators/model_interface/templates/query_interface.rb.tt @@ -24,7 +24,7 @@ module Types TEXT end <%- else -%> - argument :slug, Types::SlugType, required: true do + argument :slug, Support::GQL::SlugType, required: true do description <<~TEXT The slug to look up. TEXT diff --git a/lib/generators/model_interface/templates/selector_input.rb.tt b/lib/generators/model_interface/templates/selector_input.rb.tt index b72efc4a..d62ecace 100644 --- a/lib/generators/model_interface/templates/selector_input.rb.tt +++ b/lib/generators/model_interface/templates/selector_input.rb.tt @@ -16,7 +16,7 @@ module Types description "The unique identifier for the `<%= model_name %>`." end - argument :slug, Types::SlugType, required: false do + argument :slug, Support::GQL::SlugType, required: false do description "The slug for the `<%= model_name %>`." end end diff --git a/lib/generators/model_type/templates/model.rb.tt b/lib/generators/model_type/templates/model.rb.tt index 83014fe3..47e44861 100644 --- a/lib/generators/model_type/templates/model.rb.tt +++ b/lib/generators/model_type/templates/model.rb.tt @@ -9,7 +9,7 @@ module Types # @see <%= full_connection_type_name %> # @see <%= full_edge_type_name %> <%- end -%> - class <%= graphql_type_name %> < Types::AbstractModel + class <%= graphql_type_name %> < Types::BaseModel description <<~TEXT A database-backed model. TEXT diff --git a/lib/generators/template/templates/gql/definition.rb.tt b/lib/generators/template/templates/gql/definition.rb.tt index 2a68fa13..91cc25de 100644 --- a/lib/generators/template/templates/gql/definition.rb.tt +++ b/lib/generators/template/templates/gql/definition.rb.tt @@ -6,7 +6,7 @@ module Types module Templates # @see ::Templates::<%= class_name %>Definition - class <%= class_name %>TemplateDefinitionType < AbstractModel + class <%= class_name %>TemplateDefinitionType < ::Types::BaseModel implements ::Types::TemplateDefinitionType field :slots, ::Types::Templates::<%= class_name %>TemplateDefinitionSlotsType, null: false do diff --git a/lib/generators/template/templates/gql/instance.rb.tt b/lib/generators/template/templates/gql/instance.rb.tt index 28852c14..bbcab07f 100644 --- a/lib/generators/template/templates/gql/instance.rb.tt +++ b/lib/generators/template/templates/gql/instance.rb.tt @@ -6,7 +6,7 @@ module Types module Templates # @see ::Templates::<%= class_name %>Instance - class <%= class_name %>TemplateInstanceType < AbstractModel + class <%= class_name %>TemplateInstanceType < ::Types::BaseModel implements ::Types::TemplateInstanceType <%- if template_record.has_contribution_list? -%> implements ::Types::TemplateHasContributionListType diff --git a/lib/generators/vog/filtering_scope/USAGE b/lib/generators/vog/filtering_scope/USAGE new file mode 100644 index 00000000..4cc6d0b9 --- /dev/null +++ b/lib/generators/vog/filtering_scope/USAGE @@ -0,0 +1,8 @@ +Description: + Generate the required file(s) associated with a Filtering scope. + +Example: + bin/rails generate vog:filtering_scope Thing + + This will create: + app/graphql/types/filtering/thing_filter_input_type.rb diff --git a/lib/generators/vog/filtering_scope/filtering_scope_generator.rb b/lib/generators/vog/filtering_scope/filtering_scope_generator.rb new file mode 100644 index 00000000..c22eec8f --- /dev/null +++ b/lib/generators/vog/filtering_scope/filtering_scope_generator.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module VOG + class FilteringScopeGenerator < Rails::Generators::NamedBase + namespace "vog:filtering_scope" + + source_root File.expand_path("templates", __dir__) + + FILTERING_INPUTS = Rails.root.join("app", "graphql", "types", "filtering") + + FILTER_SCOPE_SPECS = Rails.root.join("spec", "services", "filtering", "scopes") + + def build_filter_input_type + template "filter_input.rb.tt", FILTERING_INPUTS.join( + "#{file_name}_filter_input_type.rb" + ) + end + + def build_filter_scope_spec + spec_path = FILTER_SCOPE_SPECS.join("#{file_name}_filter_scope_spec.rb") + + # We don't want to overwrite existing specs when regenerating + return if spec_path.exist? + + template "filter_scope_spec.rb.tt", spec_path + end + + private + + def argument_call_for(key, dry_type) + typing = dry_type.gql_typing + input_key = typing.input_key_for key + opts = typing.argument_options + type = opts.delete :type + raw_desc = opts.delete(:description) || "Filter by #{key.to_s.humanize.downcase}." + desc = raw_desc.to_s.indent(2).strip + + opt_str = [].tap do |arr| + arr << "loads: ::#{opts[:loads].name}" if opts[:loads].present? + arr << "as: #{key.to_sym.inspect}" if input_key.to_s != key.to_s + arr << "required: #{opts[:required].present?}" + arr << "default_value: #{opts[:default_value].inspect}" if opts.key?(:default_value) && !opts[:default_value].nil? + arr << "replace_null_with_default: true" if opts[:replace_null_with_default] + end.join(", ") + + declaration = [].tap do |dec| + dec << input_key.inspect + dec << arg_type_inspect(type) + dec << opt_str unless opt_str.empty? + end.join(", ") + + <<~RUBY.indent(6).strip + argument #{declaration} do + description <<~TEXT + #{desc} + TEXT + end + RUBY + end + + def arg_type_inspect(type) + case type + in Array + type.map { arg_type_inspect(_1) }.join(", ").then { "[#{_1}]" } + in Class + "::#{type.name}" + in Hash + [].tap do |out| + out << "{ " + type.each do |k, v| + out << "#{k}: #{arg_type_inspect(v)}" + end + out << " }" + end.join + else + type.to_s + end + end + + def scope_klass + @scope_klass ||= scope_klass_name.constantize + end + + def scope_klass_name + @scope_klass_name ||= derive_scope_klass_name + end + + def derive_scope_klass_name + "::Filtering::Scopes::#{class_name.pluralize}" + end + end +end diff --git a/lib/generators/vog/filtering_scope/templates/filter_input.rb.tt b/lib/generators/vog/filtering_scope/templates/filter_input.rb.tt new file mode 100644 index 00000000..60d01faa --- /dev/null +++ b/lib/generators/vog/filtering_scope/templates/filter_input.rb.tt @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Filtering + # @see <%= scope_klass_name %> + class <%= class_name %>FilterInputType < ::Support::GQL::BaseFilterScopeInputObject + description <<~TEXT + Filtering options for `<%= class_name %>` records. + TEXT + + inherit_from!(<%= scope_klass_name %>) + <%- scope_klass.arguments.each do |key, dry_type| -%> + <%- typing = dry_type.gql_typing -%> + <%- input_key = typing.input_key_for key -%> + <%- opts = typing.argument_options -%> + <%- opts[:as] = key.to_sym -%> + <%- type = opts.delete :type -%> + + <%= argument_call_for(key, dry_type) %> + <%- end -%> + end + end +end diff --git a/lib/generators/vog/filtering_scope/templates/filter_scope_spec.rb.tt b/lib/generators/vog/filtering_scope/templates/filter_scope_spec.rb.tt new file mode 100644 index 00000000..895d28e7 --- /dev/null +++ b/lib/generators/vog/filtering_scope/templates/filter_scope_spec.rb.tt @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe <%= scope_klass_name %>, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/lib/generators/vog/filtering_scopes/USAGE b/lib/generators/vog/filtering_scopes/USAGE new file mode 100644 index 00000000..38020348 --- /dev/null +++ b/lib/generators/vog/filtering_scopes/USAGE @@ -0,0 +1,8 @@ +Description: + Generate all related files for Filtering scopes. + +Example: + bin/rails generate vog:filtering_scopes + + This will create: + app/graphql/types/filtering/thing_filter_input_type.rb diff --git a/lib/generators/vog/filtering_scopes/filtering_scopes_generator.rb b/lib/generators/vog/filtering_scopes/filtering_scopes_generator.rb new file mode 100644 index 00000000..10effa62 --- /dev/null +++ b/lib/generators/vog/filtering_scopes/filtering_scopes_generator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module VOG + # @see VOG::FilteringScopeGenerator + class FilteringScopesGenerator < Rails::Generators::Base + namespace "vog:filtering_scopes" + + source_root File.expand_path("templates", __dir__) + + SCOPES = Rails.root.join("app", "services", "filtering", "scopes") + + def compile_existing_filtering_scopes + SCOPES.children.each do |scope_file| + require scope_file + end + + scopes = Filtering::FilterScope.descendants.select { _1.name.present? } + + scopes.each do |scope_klass| + model_name = scope_klass.model_klass.model_name.to_s + + generate "vog:filtering_scope", [model_name] + end + end + end +end diff --git a/lib/support/boot/050_custom_logic_predicates.rb b/lib/support/boot/050_custom_logic_predicates.rb index 6e21def4..176f95b8 100644 --- a/lib/support/boot/050_custom_logic_predicates.rb +++ b/lib/support/boot/050_custom_logic_predicates.rb @@ -8,6 +8,10 @@ input.present? end +Dry::Logic::Predicates.predicate :anonymous_user? do |input| + type?(Support::Users::AnonymousInterface, input) && input.anonymous? +end + Dry::Logic::Predicates.predicate :email? do |input| format? URI::MailTo::EMAIL_REGEXP, input end diff --git a/lib/support/boot/070_dry_graphql.rb b/lib/support/boot/070_dry_graphql.rb index 4637cf84..ea3dbcd1 100644 --- a/lib/support/boot/070_dry_graphql.rb +++ b/lib/support/boot/070_dry_graphql.rb @@ -11,6 +11,18 @@ end end +Dry::Types.define_builder :gql_default do |type, gql_default_value, replace_null = false| + next type if gql_default_value.nil? + + gql_replace_null = Support::Types::SafeBoolean[replace_null] + + type.meta( + gql_has_default: true, + gql_default_value:, + gql_replace_null: + ).set_gql_typing +end + Dry::Types.define_builder :gql_loads do |type, arg| type.meta( gql_type: ::Support::System["dry_gql.find_gql_type"].(:id), @@ -53,13 +65,19 @@ def derive_gql_typing loads = meta[:gql_loads] description = meta[:gql_description] required = meta[:gql_required] + default_value = meta[:gql_default_value] + replace_null = meta[:gql_replace_null] + has_default_value = meta[:gql_has_default] || false Support::DryGQL::Typing.new( actual_type:, loads:, array:, description:, - required: + required:, + default_value:, + has_default_value:, + replace_null:, ) end diff --git a/lib/support/lib/authorization/action.rb b/lib/support/lib/authorization/action.rb deleted file mode 100644 index f55a0340..00000000 --- a/lib/support/lib/authorization/action.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # A condition that resolves a validated monad for classes that implement - # {Support::Authorization::DefinesConditions}. - # - # @api private - class Action - include Support::Authorization::NormalizesCondition - - include Support::Typing - - include Dry::Core::Memoizable - - include Dry::Initializer[undefined: false].define -> do - param :name, Types::ConditionName - - param :conditions, Types::ConditionList.constrained(min_size: 1) - - option :logic, Types::ConditionLogic, default: proc { :all } - end - - # @param [Support::Authorization::DefinesConditions] context - # @return [Dry::Monads::Validated] - def call(context) - context.resolve_condition([logic, conditions]) - end - - private - - # @return [Symbol, Array] - memoize def condition - normalize_condition [logic, conditions] - end - end - end -end diff --git a/lib/support/lib/authorization/condition.rb b/lib/support/lib/authorization/condition.rb deleted file mode 100644 index 93384375..00000000 --- a/lib/support/lib/authorization/condition.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # A condition that resolves a validated monad for classes that implement - # {Support::Authorization::DefinesConditions}. - # - # @api private - class Condition - include Support::Authorization::NormalizesCondition - include Support::Typing - - include Dry::Core::Memoizable - - include Dry::Monads[:list, :result, :validated, :do] - - include Dry::Initializer[undefined: false].define -> do - param :name, Types::ConditionName - - param :resolver, Types::ConditionResolver - - option :from, Types::Dependencies, default: proc { [] } - option :logic, Types::ConditionLogic, default: proc { :all } - end - - # @param [Support::Authorization::DefinesConditions] context - def call(context) - yield resolve_dependencies_for(context) if from.present? - - is_valid = context.instance_eval(&resolver) - - is_valid ? Valid(name) : Invalid(name) - end - - private - - # @return [Symbol, Array] - memoize def dependency_condition - normalize_condition [logic, from] - end - - # @return [Dry::Monads::Validated] - def resolve_dependencies_for(context) - dependencies = context.resolve_condition(dependency_condition) - - dependencies.alt_map do |failures| - [*failures, name] - end - end - end - end -end diff --git a/lib/support/lib/authorization/defines_actions.rb b/lib/support/lib/authorization/defines_actions.rb deleted file mode 100644 index 91714261..00000000 --- a/lib/support/lib/authorization/defines_actions.rb +++ /dev/null @@ -1,135 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # Define actions (with matching predicates) for use in policy classes. - # - # Subpolicies can override without affecting their parents. - module DefinesActions - extend ActiveSupport::Concern - - include DefinesConditions - - ActionResolution = Support::Authorization::Action::Type | Support::Authorization::DelegatedAction - - ActionMapping = Types::Hash.map(Types::ActionName, ActionResolution) - - included do - extend Dry::Core::ClassAttributes - - extend Support::TypedSet.of(Types::ActionName)[:actions] - - extend Support::TypedSet.of(Types::Predicate)[:predicates] - - defines :action_mapping, type: ActionMapping - - action_mapping({}.freeze) - end - - # @param [Symbol] name - # @return [Dry::Monads::Validated] - def resolve_action(name) - action = action_mapping.fetch name - - action.call(self) - end - - # @param [Symbol] name - def resolve_action?(name) - resolve_action(name).to_result.success? - end - - private - - # @return [{ Symbol => Support::Authorization::Action, Support::Authorization:DelegatedAction }] - def action_mapping - self.class.action_mapping - end - - module ClassMethods - # @see Support::Authorization::DelegatedAction - def delegate_action!(from, to:) - set_action! from, Support::Authorization::DelegatedAction.new(from:, to:) - end - - # @param [] names - # @param [Symbol] to - def delegate_actions!(*names, to:) - Types::ActionNames[names].each do |from| - delegate_action! from, to: - end - end - - # @param [Array] conditions - # @param [] aliases - # @param [Symbol, ] on - # @param [:all, :any, :nor, :not] logic - # @return [void] - def require_condition!(*conditions, on:, logic: :all, aliases: []) - case on - in Types::ActionName => to - set_action_resolution!(to, conditions, logic:) - - delegate_actions!(*aliases, to:) if aliases.any? - in Types::ActionNames => action_names - # :nocov: - raise ArgumentError, "cannot specify `aliases` when `on` is an array" if aliases.any? - # :nocov: - - action_names.each do |condition_name| - set_action_resolution!(condition_name, conditions, logic:) - end - end - end - - # Wrap the list of incoming conditions as needing at least one to be satisfied. - def require_any_condition!(*conditions, **args) - args[:logic] = :any - - require_condition!(*conditions, **args) - end - - private - - # @param [Symbol] name - # @return [void] - def define_action_predicate_for!(name) - actions actions.add(name) - - predicate = :"#{name}?" - - predicates predicates.add(predicate) - - return if method_defined? predicate - - class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def #{predicate} # def read? - resolve_action?(#{name.inspect}) # resolve_action?(:read) - end # end - RUBY - end - - # @param [Symbol] name - # @param [Action, DelegatedAction] action - # @return [void] - def set_action!(name, action) - define_action_predicate_for! name - - current_mapping = action_mapping - - action_mapping current_mapping.merge(name => action).freeze - end - - # @param [Symbol] name - # @param [Array] conditions - # @param [:all, :any, :nor, :not] logic - # @return [void] - def set_action_resolution!(name, conditions, logic: :all) - action = Support::Authorization::Action.new(name, conditions, logic:) - - set_action! name, action - end - end - end - end -end diff --git a/lib/support/lib/authorization/defines_conditions.rb b/lib/support/lib/authorization/defines_conditions.rb deleted file mode 100644 index 1ef43cbd..00000000 --- a/lib/support/lib/authorization/defines_conditions.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # Define boolean conditions that must be met in order to satisfy authorization actions. - module DefinesConditions - extend ActiveSupport::Concern - - include Dry::Monads[:list, :validated, :result] - - ConditionResolution = Support::Authorization::Condition::Type | Support::Authorization::DelegatedCondition - - ConditionMapping = Types::Hash.map(Types::ConditionName, ConditionResolution) - - ConditionSource = Types::Class.constrained(lt: self) - - FLATTEN_CONDITIONS = proc { _1.to_a.flatten } - - included do - extend Dry::Core::ClassAttributes - - extend Support::TypedSet.of(Types::ConditionName)[:conditions] - - defines :condition_mapping, type: ConditionMapping - - condition_mapping({}.freeze) - end - - # @param [(:and, ConditionList), (:any, ConditionList), (:not, ConditionName), ConditionName] condition - # @return [Dry::Monads::Validated] - def resolve_condition(condition) - case condition - in :all, Array => conditions - resolve_all_conditions(conditions) - in :any, Array => conditions - resolve_any_conditions(conditions) - in :nor, Array => conditions - resolve_nor_conditions(conditions) - in :not, Array => conditions - resolve_not_conditions(conditions) - in Types::ConditionName => condition_name - resolve_single_condition(condition_name) - else - # :nocov: - raise Support::Authorization::InvalidCondition, condition - # :nocov: - end - end - - # @param [(:and, ConditionList), (:any, ConditionList), (:not, ConditionName), ConditionName] condition - def resolve_condition?(condition) - resolve_condition(condition).to_result.success? - end - - private - - # @param [Dry::Monads::Validated] validated - # @return [Dry::Monads::Validated] - def negate_condition(validated) - validated.to_result.flip.to_validated - end - - # @param [Array] conditions (@see #resolve_condition) - # @return [] - def map_conditions(conditions) - # :nocov: - raise ArgumentError, "must have at least one condition" if conditions.blank? - # :nocov: - - conditions.map do |cond| - resolve_condition cond - end - end - - # @param [Array] conditions (@see #resolve_condition) - # @return [Dry::Monads::Validated] - def resolve_all_conditions(conditions) - traverse_conditions map_conditions conditions - end - - # @param [Array] conditions (@see #resolve_condition) - # @return [Dry::Monads::Validated] - def resolve_any_conditions(conditions) - valid, invalid = map_conditions(conditions).partition { _1.to_result.success? } - - traverse_conditions valid.presence || invalid - end - - # @param [Array] conditions (@see #resolve_condition) - # @return [Dry::Monads::Validated] - def resolve_nor_conditions(conditions) - negate_condition resolve_any_conditions(conditions) - end - - # @param [Array] conditions (@see #resolve_condition) - # @return [Dry::Monads::Validated] - def resolve_not_conditions(conditions) - negate_condition resolve_all_conditions(conditions) - end - - # @return [Dry::Monads::Validated] - def resolve_single_condition(condition_name) - if has_condition?(condition_name) - cond = self.class.condition_mapping.fetch(condition_name) - - cond.call(self) - else - raise Support::Authorization::InvalidCondition, condition_name - end - end - - # @param [] validations - # @return [Dry::Monads::Validated] - def traverse_conditions(validations) - List::Validated.coerce(validations).traverse.fmap(FLATTEN_CONDITIONS).alt_map(FLATTEN_CONDITIONS) - end - - module ClassMethods - # @param [Symbol] name - # @return [void] - def define_condition!(name, meth = nil, **options, &resolver) - if meth.present? && block_given? - # :nocov: - raise ArgumentError, "cannot provide both a method and a resolver for #{self} condition: #{name.inspect}" - # :nocov: - end - - condition = Support::Authorization::Condition.new(name, meth || resolver, **options) - - set_condition! name, condition - end - - # @param [Class] klass - # @param [Symbol] source the name of the attribute that provides an instance of said klass - # @return [void] - def inherit_conditions_from!(klass, source) - ConditionSource[klass].conditions.each do |name| - delegated = Support::Authorization::DelegatedCondition.new(name:, klass:, source:) - - set_condition! name, delegated - end - end - - private - - # @param [Symbol] name - # @param [Object] value - # @return [void] - def set_condition!(name, value) - add_condition! name - - current_mapping = condition_mapping - - condition_mapping current_mapping.merge(name => value).freeze - end - end - end - end -end diff --git a/lib/support/lib/authorization/delegated_action.rb b/lib/support/lib/authorization/delegated_action.rb deleted file mode 100644 index 091683d2..00000000 --- a/lib/support/lib/authorization/delegated_action.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # A type that simply maps one action to another when resolving - # whether or not something can be done, based on the current value. - # - # i.e. `policy.index?` being delegated to `policy.read?`, without - # having to redefine `index?` in an inherited policy if it alters - # the conditions needed to satisfy `read`. - class DelegatedAction < Support::FlexibleStruct - attribute :from, Types::ActionName - attribute :to, Types::ActionName - - # @param [Support::Authorization::DefinesActions] context - # @return [Dry::Monads::Validated] - def call(context) - context.resolve_action(to) - end - end - end -end diff --git a/lib/support/lib/authorization/delegated_condition.rb b/lib/support/lib/authorization/delegated_condition.rb deleted file mode 100644 index 67a26ce0..00000000 --- a/lib/support/lib/authorization/delegated_condition.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # A type that delegates a condition to another source - # on the object - class DelegatedCondition < Support::FlexibleStruct - attribute :name, Types::ConditionName - attribute :source, Types::ConditionName - attribute :klass, Types::Class - - # @param [Support::Authorization::DefinesConditions] context - # @return [Dry::Monads::Validated] - def call(context) - actual_context = context.public_send(source) - - actual_context.resolve_condition(name) - end - end - end -end diff --git a/lib/support/lib/authorization/invalid_condition.rb b/lib/support/lib/authorization/invalid_condition.rb deleted file mode 100644 index 5d349fb9..00000000 --- a/lib/support/lib/authorization/invalid_condition.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # An error raised when feeding an invalid condition - # to the identity processor. This indicates a logic - # error or a typo somewhere in the code. - class InvalidCondition < StandardError - attr_reader :condition - - def initialize(condition, *extra) - @condition = condition - - super("Cannot process #{condition.inspect}", *extra) - end - end - end -end diff --git a/lib/support/lib/authorization/normalizes_condition.rb b/lib/support/lib/authorization/normalizes_condition.rb deleted file mode 100644 index bfacc4c8..00000000 --- a/lib/support/lib/authorization/normalizes_condition.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - # Normalize simple condition checks to remove logic when it isn't necessary. - module NormalizesCondition - module_function - - # @param [(Symbol, Array), (Symbol, (Symbol))] input - # @return [Symbol] - # @return [(Symbol, Array)] - def normalize_condition(input) - case input - in Types::ConditionPositiveLogic, Types::SingleCondition => condition - condition.first - in Types::ConditionLogic => logic, Types::Conditions => conditions - [logic, conditions] - else - raise InvalidCondition, input - end - end - end - end -end diff --git a/lib/support/lib/authorization/types.rb b/lib/support/lib/authorization/types.rb deleted file mode 100644 index fc68da25..00000000 --- a/lib/support/lib/authorization/types.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module Support - module Authorization - module Types - extend ::Support::Typespace - - # An action - ActionName = Coercible::Symbol.constrained(format: /\A[a-z]\w+[a-z]\z/) - - ActionNames = Coercible::Array.of(ActionName) - - Callable = Interface(:call) - - # A list of values that can satisfy an {ActionName} - ConditionList = Array - - Conditions = ConditionList.constrained(min_size: 1) - - ConditionLogic = Coercible::Symbol.default(:all).enum(:all, :any, :not, :nor) - - ConditionPositiveLogic = Coercible::Symbol.default(:all).enum(:all, :any) - - ConditionName = Coercible::Symbol.constrained(format: /\A[a-z]\w+[a-z]\z/, excluded_from: ConditionLogic) - - ConditionNames = Coercible::Array.of(ConditionName) - - ConditionResolver = Callable.constructor do |input| - case input - when Symbol then input.to_proc - else - input - end - end - - Dependencies = Coercible::Array - - Predicate = Coercible::Symbol.constrained(format: /\A[a-z]\w+[a-z]\?\z/) - - Predicates = Coercible::Array.of(Predicate) - - SingleCondition = Array.of(ConditionName).constrained(size: 1) - end - end -end diff --git a/lib/support/lib/dry_gql/model_reference.rb b/lib/support/lib/dry_gql/model_reference.rb new file mode 100644 index 00000000..0bd60568 --- /dev/null +++ b/lib/support/lib/dry_gql/model_reference.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Support + module DryGQL + # A lazily-evaluated reference to a model that can be used to evaluate and fetch + # details about how the model is used within the application's GraphQL API. + class ModelReference < ::Support::Models::Reference + option :container, ::Support::DryGQL::TypeContainer::Type + + option :as_type, Types::TypeReference.optional, optional: true, as: :provided_type + + option :single_key, Types::ModelKey, default: -> { model_name.singular } + + option :plural_key, Types::ModelKey, default: -> { model_name.plural } + + option :reference_key, Types::ModelKey, default: -> { single_key } + + # @!attribute [r] as_type + # The GraphQL type to use for this model reference, which can be a string to lazily load the type from the container, or an actual GraphQL type. + # + # It will attempt to derive the type from the model if it can. + # + # @return [GraphQL::Schema::Member, String] + def as_type + @as_type or realized! + end + + # @!attribute [r] single_type + # A dry type representing a single instance of the referenced model. + # + # @return [Support::DryGQL::Types::Type] + def single_type + @single_type or realized! + end + + # @!attribute [r] plural_type + # A dry type representing an array of instances of the referenced model. + # + # @return [Support::DryGQL::Types::Type] + def plural_type + @plural_type or realized! + end + + private + + def realization + super + + @as_type = provided_type || klass.graphql_node_type_name + @single_type = fetch_single_type + @plural_type = fetch_plural_type + end + + def fetch_single_type + ::Support::DryGQL::Types.Instance(@klass).gql_loads(@as_type).gql_description(<<~TEXT) + Filter by a single #{@klass}. + TEXT + end + + def fetch_plural_type + Types::Array.of(@single_type).gql_loads(@as_type).gql_description(<<~TEXT) + Filter by multiple #{@klass}. + TEXT + end + end + end +end diff --git a/lib/support/lib/dry_gql/order_enum_introspector.rb b/lib/support/lib/dry_gql/order_enum_introspector.rb index b0c8e764..3aae1c52 100644 --- a/lib/support/lib/dry_gql/order_enum_introspector.rb +++ b/lib/support/lib/dry_gql/order_enum_introspector.rb @@ -5,7 +5,7 @@ module DryGQL class OrderEnumIntrospector include Dry::Core::Memoizable include Dry::Initializer[undefined: false].define -> do - param :enum, Support::DryGQL::Types::EnumClass + param :enum, Support::DryGQL::Types::EnumType end ORDER_PRIORITY = %w[DEFAULT RECENT].freeze diff --git a/lib/support/lib/dry_gql/type_container.rb b/lib/support/lib/dry_gql/type_container.rb index 48603ddd..cffcc8db 100644 --- a/lib/support/lib/dry_gql/type_container.rb +++ b/lib/support/lib/dry_gql/type_container.rb @@ -2,23 +2,68 @@ module Support module DryGQL + # @abstract A container for iteratively building up a set of types that can be used + # interchangeably as both dry types and GraphQL types. class TypeContainer + extend ActiveModel::Callbacks + extend Dry::Core::ClassAttributes + include Dry::Container::Mixin + include Support::Typing + + defines :enum_types, type: Types::EnumTypes + defines :model_names, type: Types::Array.of(Types::String) + + enum_types Dry::Core::Constants::EMPTY_ARRAY + + model_names Dry::Core::Constants::EMPTY_ARRAY + + define_model_callbacks :compile_types, :compile_models + define_model_callbacks :initialize, only: %i[after] + + # @return [Boolean] + attr_reader :compiled + + alias compiled? compiled + + # @return [ActiveSupport::HashWithIndifferentAccess{String, Symbol => Class(GraphQL::Schema::Enum)}] + attr_reader :enum_types + + # @return [Boolean] + attr_reader :has_compiled_models + + alias has_compiled_models? has_compiled_models + + # @return [ActiveSupport::HashWithIndifferentAccess{String, Symbol => ModelReference}] + attr_reader :models def initialize(...) super merge Support::DryGQL::DefaultTypings - add_model! ::User - end + @enum_types = {}.with_indifferent_access - def configure - yield self + @models = {}.with_indifferent_access - return self + run_callbacks :initialize + + run_callbacks :compile_types do + compile_models! + + realize_models! + + compile_enums! + + @compiled = true + end + + freeze end + # @param [String, Symbol] name + # @param [Dry::Types::Type] type + # @return [void] def add!(name, type) # :nocov: raise "must have gql typing" unless type.has_gql_typing? @@ -27,35 +72,121 @@ def add!(name, type) register(name, type) end - # @param [#to_s] key - # @param + # @param [String, Symbol] name + # @yield a lazily-evaluated block that returns a `Dry::Types::Type` + # @yieldreturn [Dry::Types::Type] + # @return [void] + def add_lazy!(name) + register(name, memoize: true) do + type = yield + + # :nocov: + raise "must have gql typing" unless type.has_gql_typing? + # :nocov: + + type + end + end + + # @param [Class(GraphQL::Schema::Enum)] enum_klass + # @param [String, nil] single_key + # @param [String, nil] plural_key + # @return [void] def add_enum!(enum_klass, single_key: nil, plural_key: nil) - single_type = Types::EnumClass[enum_klass].dry_type + single_type = Types::EnumType[enum_klass].dry_type plural_type = Types::Array.of(single_type).gql_type(enum_klass) single_key ||= enum_klass.graphql_name.underscore plural_key ||= enum_klass.graphql_name.tableize + @enum_types[single_key] = enum_klass + add! single_key, single_type add! plural_key, plural_type end - def add_model!(klass, as_type: klass.graphql_node_type_name, single_key: klass.model_name.singular, plural_key: klass.model_name.plural) - single_type = Types.Instance(klass).gql_loads(as_type).gql_description(<<~TEXT) - Filter by a single #{klass}. - TEXT + # @param [String] klass_name + # @param [Hash] options (@see Support::DryGQL::ModelReference) + # @return [void] + def add_model!(klass_name, **options) + reference = ModelReference.new(klass_name, **options, container: self) - plural_type = Types::Array.of(single_type).gql_loads(as_type).gql_description(<<~TEXT) - Filter by an array of #{klass} records, matching one or more. - TEXT + @models[reference.reference_key] = reference - add! single_key, single_type - add! plural_key, plural_type - add! klass, single_type - add! [klass], plural_type + add_lazy! reference.single_key do + reference.single_type + end + + add_lazy! reference.plural_key do + reference.plural_type + end return self end + + private + + # @return [void] + def compile_enums! + self.class.enum_types.each do |enum_klass| + add_enum! enum_klass + end + end + + # @return [void] + def compile_models! + run_callbacks :compile_models do + self.class.model_names.each do |model_name| + add_model! model_name + end + + @has_compiled_models = true + end + end + + # @return [void] + def realize_models! + models.each_value(&:realize!) + end + + class << self + # @param [] enum_types + # @return [void] + def add_enum_types!(*enum_types) + new_types = enum_types.flatten.compact_blank + + merged_types = (enum_types | new_types).sort_by(&:graphql_name).freeze + + enum_types merged_types + end + + # @param [Class(GraphQL::Schema::Enum)] enum_type + # @return [void] + def add_enum_type!(enum_type) + add_enum_types! enum_type + end + + # @param [] names + # @return [void] + def add_models!(*names) + new_names = names.flatten.compact_blank + + merged_names = (model_names | new_names).sort.freeze + + model_names merged_names + end + + # @param [String] name + # @return [void] + def add_model!(name) + add_models! name + end + + # @return [void] + def compile!(...) + before_compile_types(...) + end + end end end end diff --git a/lib/support/lib/dry_gql/types.rb b/lib/support/lib/dry_gql/types.rb index 5466670d..6ad0e194 100644 --- a/lib/support/lib/dry_gql/types.rb +++ b/lib/support/lib/dry_gql/types.rb @@ -2,18 +2,73 @@ module Support module DryGQL + # Types for working with dry-rb and graphql-ruby integrations. module Types extend ::Support::Typespace - EnumClass = Class.constrained(inherits: ::GraphQL::Schema::Enum) + # A type matching a GraphQL enum class. + # + # @return [Dry::Types::Type] + EnumType = Class.constrained(inherits: ::GraphQL::Schema::Enum) + # A type matching an array of GraphQL enum classes. + # + # @return [Dry::Types::Type] + EnumTypes = Array.of(EnumType) + + # A type matching a string that can be used to lazily load a GraphQL type from the Types namespace. + # + # @return [Dry::Types::Type] LazyLoadTypeName = String.constrained(format: /\A(?:::)?Types::[A-Z]\S+\z/) + # A string that can be used as a key for referencing a model type. + # + # @return [Dry::Types::Type] + ModelKey = Coercible::String.constrained(filled: true) + + # @see Support::Models::Types::Name + # + # @return [Dry::Types::Type] + ModelName = Support::Models::Types::Name + + # @see Support::Models::Types::Names + # + # @return [Dry::Types::Type] + ModelNames = Support::Models::Types::Names + + # A GraphQL query with no schema, for building query contexts. + # + # @api private + NULL_QUERY = GraphQL::Query.new(::GraphQL::Schema) + + # A builder for a null query context. + # It accepts a hash of values to set and produces a `GraphQL::Query::Context`. + # + # @api private + BUILD_NULL_CONTEXT = proc do |**values| + GraphQL::Query::Context.new(query: NULL_QUERY, values:) + end + + # A GraphQL query context. + # + # It will fall back to a null context generated by {BUILD_NULL_CONTEXT} if none is provided. + QueryContext = Instance(::GraphQL::Query::Context).default { BUILD_NULL_CONTEXT.() } + + # A type matching a `GraphQL::Schema::Member` class, which could be an object, enum, etc. + # + # @return [Dry::Types::Type] SchemaMember = Class.constrained(inherits: ::GraphQL::Schema::Member) + # A type matching either a GraphQL::Schema::Member or a string that can be used to lazily load one. + # @see LazyLoadTypeName + # @see SchemaMember + # + # @return [Dry::Types::Type] TypeReference = LazyLoadTypeName | SchemaMember # A dry-type with GQL typing + # + # @return [Dry::Types::Type] Type = ::Support::Types::DryType.constrained(gql_typing: true) end end diff --git a/lib/support/lib/dry_gql/typing.rb b/lib/support/lib/dry_gql/typing.rb index 11ba372f..2dcfe9b9 100644 --- a/lib/support/lib/dry_gql/typing.rb +++ b/lib/support/lib/dry_gql/typing.rb @@ -3,8 +3,6 @@ module Support module DryGQL class Typing < Support::FlexibleStruct - include Dry::Core::Memoizable - attribute :actual_type, Types::TypeReference attribute :loads, Types::TypeReference.optional.default(nil).fallback(nil) @@ -12,6 +10,26 @@ class Typing < Support::FlexibleStruct attribute? :array_member_null, Types::Bool.default(false) attribute? :description, Types::String.optional.default(nil).fallback(nil) attribute? :required, Types::Bool.default(false).fallback(false) + attribute? :replace_null, Types::Bool.default(false).fallback(false) + + attribute? :default_value, Types::Any.optional.default(nil) + attribute? :has_default_value, Types::Bool.default(false).fallback(false) + + alias has_default_value? has_default_value + + alias replace_null_with_default replace_null + + # @return [Class] + # @return [String] a string reference to a GraphQL object class, for lazy-loading. + # @return [(Class, Hash)] + # @return [(String, Hash)] a string reference to a GraphQL object class, for lazy-loading. + attr_reader :type + + def initialize(...) + super + + @type = realize_type + end def as_array self.class.new(attributes.merge(array: true)) @@ -20,6 +38,10 @@ def as_array def argument_options opts = { type:, required: } + if has_default_value? + opts.merge!(default_value:, replace_null_with_default:) + end + if loads.present? opts[:loads] = loads.kind_of?(String) ? loads.constantize : loads end @@ -35,11 +57,10 @@ def input_key_for(base) array ? :"#{base}_ids" : :"#{base}_id" end - # @return [Class] - # @return [String] a string reference to a GraphQL object class, for lazy-loading. - # @return [(Class, Hash)] - # @return [(String, Hash)] a string reference to a GraphQL object class, for lazy-loading. - memoize def type + private + + # @return [Object] (@see #type) the actual type declaration to be used in GraphQL argument definitions. + def realize_type return actual_type unless array array_options = { null: array_member_null } diff --git a/lib/support/lib/filtering/abstract_scope.rb b/lib/support/lib/filtering/abstract_scope.rb new file mode 100644 index 00000000..8b1cb916 --- /dev/null +++ b/lib/support/lib/filtering/abstract_scope.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +module Support + module Filtering + # The base class for building filter scopes. It has a fluent DSL for defining filtering + # arguments, and methods for applying those filters to an ActiveRecord scope. + # + # @abstract + class AbstractScope < Support::QueryResolver::Base + extend Dry::Initializer + + include ::Support::Typing + + Subclass = ::Support::Filtering::Types.Inherits(self) + + defines :required_scopes, type: ::Support::Filtering::Types::ScopeNames + + required_scopes Dry::Core::Constants::EMPTY_ARRAY + + define_model_callbacks :ranking, only: %i[before after] + + defines :model_klass, type: ::Support::Models::Types::ModelClass + + model_klass Support::NullRecord + + defines :input_object_name, type: Support::Filtering::Types::String + + input_object_name "" + + def initialize(...) + super + + @filter_inputs = build_filter_inputs + end + + # @see Filtering::Applicator#call + # @param [ActiveRecord::Relation] top_level_scope + # @return [ActiveRecord::Relation] + def apply_to(top_level_scope) + applicator.(top_level_scope) + end + + # @return [ActiveRecord::Relation] + def initialize_scope + self.class.model_klass.all + end + + # Finalize the scope by reselecting only the primary key and removing any ordering. + # + # @return [void] + def finalize! + augment_scope! do |sc| + sc.reselect(sc.primary_key).reorder(nil) + end + end + + # @param [ActiveRecord::Relation] base + # @return [ActiveRecord::Relation] + def apply_ranking_to(base) + @ranking_scope = base + + run_callbacks :ranking + + return @ranking_scope + ensure + @ranking_scope = nil + end + + # @yieldparam [ActiveRecord::Relation] ranking_scope + # @yieldreturn [ActiveRecord::Relation] + # @return [void] + def augment_ranking! + # :nocov: + new_scope = yield @ranking_scope + + @ranking_scope = new_scope unless new_scope.nil? + # :nocov: + end + + def has_admin_access? = current_user.try(:has_admin_access?) + + private + + # @!attribute [r] applicator + # @return [Filtering::Applicator] + def applicator + @applicator ||= Filtering::Applicator.new(self) + end + + # @return [void] + def apply_all_tags! + augment_scope! do |scope| + external_tags&.call(scope, on: :external_tags) + end + + augment_scope! do |scope| + next unless internal_tags.present? + next scope.none unless has_admin_access? + + internal_tags.call(scope, on: :internal_tags) + end + end + + # @return [Hash] + def build_filter_inputs + self.class.arguments.keys.to_h do |key| + [key.to_sym, public_send(key)] + end.compact + end + + class << self + # Create a subclass of {Filtering::FilterScope} for the given model class + # to inherit from. + # + # @param [Class] klass the model class to wrap around + # @return [Class] + def [](klass) + Class.new(self).tap do |filter_scope| + filter_scope.model_klass klass + + filter_scope.input_object_name "#{klass.model_name}FilterInput" + end + end + + # @!attribute [r] input_object + # @return [Class] + def input_object + @input_object ||= "::Types::Filtering::#{model_klass.name}FilterInputType".safe_constantize + end + + # @see Resolvers::AbstractResolver.filters_with! + # @return [Hash] options for the `filters` resolver argument + def options_for_resolver + { + type: input_object, + default: arguments.default_value, + argument_options: { + replace_null_with_default: true, + }, + description: <<~TEXT + Filters that **must** match. + TEXT + } + end + + # @see Resolvers::AbstractResolver.filters_with! + # @return [Hash] options for the `orFilters` resolver argument + def options_for_or_resolver + { + type: [input_object, { null: false }], + default: [], + argument_options: { + replace_null_with_default: true, + }, + description: <<~TEXT + An array of filters, at least one of which must match. This is intended more for debugging and introspection in the API, + though a UI could be built. + + **Note**: If `filters` is also specified, at least one set of filters in `orFilters` must match, along with `filters`. + TEXT + } + end + + # @param [Symbol] scope_name + # @return [void] + def uses_scope!(scope_name) + uses_scopes! scope_name + end + + # @param [] scope_names + # @return [void] + def uses_scopes!(*scope_names) + new_scopes = scope_names.flatten.compact_blank.map { _1.to_sym } + + # :nocov: + return if new_scopes.blank? + # :nocov: + + merged_scopes = (required_scopes | new_scopes).sort + + required_scopes merged_scopes.freeze + end + end + end + end +end diff --git a/lib/support/lib/filtering/applicator.rb b/lib/support/lib/filtering/applicator.rb new file mode 100644 index 00000000..d7b0a238 --- /dev/null +++ b/lib/support/lib/filtering/applicator.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Support + module Filtering + class Applicator + include Dry::Core::Equalizer.new(:filters) + + include Dry::Initializer[undefined: false].define -> do + param :filters, ::Support::Filtering::AbstractScope::Type + end + + # @param [ActiveRecord::Relation] top_level_scope + # @return [ActiveRecord::Relation] + def call(top_level_scope) + filtered = top_level_scope.where(top_level_scope.primary_key => filtered_scope) + + filters.apply_ranking_to filtered + end + + # @api private + # @return [ActiveRecord::Relation] + def filtered_scope + @filtered_scope ||= filters.call + end + end + end +end diff --git a/lib/support/lib/filtering/argument_builder.rb b/lib/support/lib/filtering/argument_builder.rb new file mode 100644 index 00000000..3d188672 --- /dev/null +++ b/lib/support/lib/filtering/argument_builder.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Support + module Filtering + # Used within {Support::Filtering::Arguments#add!} to build argument types with a DSL. + # + # @api private + class ArgumentBuilder + include Dry::Initializer[undefined: false].define -> do + param :type, Support::DryGQL::Types::Type + + option :required, Support::DryGQL::Types::Bool.default(false), optional: true, as: :provided_required + end + + # @return [Support::DryGQL::Types::Type] + def call + @current_type = type + + required! if provided_required + + yield self if block_given? + + return @current_type + end + + # @param [Object] value + # @param [Boolean] replace_null whether to replace null values with the default value when the argument is not provided in the GraphQL query. + # @return [void] + def default(value, replace_null: false) + augment_type do |t| + augment_type do |t| + t.gql_default(value, replace_null) + end + end + end + + # Set the GraphQL description for this argument. + # @param [String] text + # @return [void] + def description(text) + augment_type do |t| + t.gql_description text + end + end + + # Mark the argument as required. + # @return [void] + def required! + augment_type do |t| + t.gql_required true + end + end + + private + + # Iteratively build the dry type by yielding to the block one or more times. + # @yieldreturn [Dry::Types::Type, nil] the block can return a new type to update the current type, or nil to keep the current type. + # @return [void] + def augment_type + new_type = yield @current_type + + @current_type = Support::DryGQL::Types::Type[new_type] if new_type.present? + + return + end + end + end +end diff --git a/lib/support/lib/filtering/arguments.rb b/lib/support/lib/filtering/arguments.rb new file mode 100644 index 00000000..886dd8ee --- /dev/null +++ b/lib/support/lib/filtering/arguments.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Support + module Filtering + # A simple container for storing filter-scope arguments, which are dry-types + # that have metadata about how to build their corresponding GQL definitions. + class Arguments + include Dry::Container::Mixin + + # @param [#to_s] key + # @param [#to_s, Class, (Class)] type_key + # @param [Hash] options + # @return [Support::DryGQL::Types::Type] + def add!(key, type_key, **options, &) + type = Common::Container["filtering.type_container"].resolve(type_key) + + configured_type = ::Support::Filtering::ArgumentBuilder.new(type, **options).call(&) + + register key, configured_type + + recalculate_default_value! + + return configured_type + end + + # @!attribute [r] default_value + # @return [Hash{Symbol => Support::DryGQL::Typing}] + def default_value + @default_value ||= calculate_default_value + end + + # @api private + # @return [void] + def recalculate_default_value! + @default_value = calculate_default_value + end + + private + + # @return [Hash{Symbol => Support::DryGQL::Typing}] + def calculate_default_value + each.each_with_object({}.with_indifferent_access) do |(key, type), defaults| + typing = type.gql_typing + + next unless typing.has_default_value? + + defaults[key] = typing.default_value + end + end + end + end +end diff --git a/lib/support/lib/filtering/common_arguments.rb b/lib/support/lib/filtering/common_arguments.rb new file mode 100644 index 00000000..52263f56 --- /dev/null +++ b/lib/support/lib/filtering/common_arguments.rb @@ -0,0 +1,409 @@ +# frozen_string_literal: true + +module Support + module Filtering + # DSL methods for defining filtering arguments on a {Filtering::FilterScope}. + # + # They are extrapolated into this module to separate argument DSLs from the core + # filtering logic. + # + # Type references used herein are resolved via {Filtering::TypeContainer}. + module CommonArguments + extend ActiveSupport::Concern + + # The class methods / DSL for defining filtering arguments. + module ClassMethods + # @note This method is largely called by the other argument-defining DSL methods. + # @param [Symbol] key + # @param [Symbol, Dry::Types::Type] type + # @param [Object, nil] default_value + # @param [Boolean, nil] replace_null + # @yield [arg] a block to further configure the argument + # @yieldparam [Filtering::ArgumentBuilder] arg + # @yieldreturn [void] + # @return [void] + def argument!(key, type, default_value: nil, replace_null: nil, **options) + dry_type = arguments.add! key, type, **options do |arg| + # :nocov: + yield arg if block_given? + # :nocov: + + arg.default(default_value, replace_null:) + end + + option key, dry_type, optional: true + end + + # @param [Symbol] key + # @param [Symbol] truthy_scope + # @param [Symbol, nil] falsey_scope + # @yield [arg] a block to further configure the argument + # @yieldparam [Filtering::ArgumentBuilder] arg + # @yieldreturn [void] + # @return [void] + def boolean_scope!(key, truthy_scope: key, falsey_scope: nil, **options, &) + argument!(key, :bool, **options, &) + + on_true = "scope.#{truthy_scope}" + + on_false = falsey_scope.present? ? "scope.#{falsey_scope}" : "scope.all" + + uses_scopes! truthy_scope, falsey_scope + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + augment_scope! do |scope| + case #{key} + when true + #{on_true} + when false + #{on_false} + end + end + end + RUBY + end + + # Define a filter for matching date columns. + # + # @param [Symbol] key + # @param [Symbol] column_name + # @return [void] + def date_match!(key, column_name: key) + # :nocov: + argument! key, :date_match do |arg| + arg.description <<~TEXT + Filter the model's `#{column_name}` with date constraints. + TEXT + end + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] + + augment_scope! do |scope| + scope.where(#{key}.(attribute)) if #{key}.present? + end + end + RUBY + # :nocov: + end + + # Define a filter for matching float / decimal columns. + # + # @param [Symbol] key + # @param [Symbol] column_name + # @return [void] + def float_match!(key, column_name: key) + # :nocov: + argument! key, :float_match do |arg| + arg.description <<~TEXT + Filter the model's `#{column_name}` with various float / decimal constraints. + TEXT + end + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] + + augment_scope! do |scope| + scope.where(#{key}.(attribute)) if #{key}.present? + end + end + RUBY + # :nocov: + end + + # Define a full-text search filter that uses {Support::FullTextSearching}. + # @param [Symbol] search_scope the name of a full-text search scope on the model + # @param [Symbol] key the argument key to use + # @return [void] + def fts_scope!(search_scope, key: :q) + argument! key, :full_text_search_query do |arg| + arg.description <<~TEXT + Perform a full-text search with the provided query. + TEXT + end + + uses_scope! search_scope + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + before_ranking def rank_#{search_scope}! + augment_ranking! do |scope| + scope.#{search_scope}(#{key}).with_pg_search_rank if #{key}.present? && #{key}.ranked_by_relevance? + end + end + + after_build def apply_#{search_scope}! + augment_scope! do |scope| + scope.#{search_scope}(#{key}) if #{key}.present? + end + end + RUBY + end + + # Define a full-text search filter. + # @param [Symbol] search_scope the name of a full-text search scope on the model + # @param [Symbol] key the argument key to use + # @return [void] + def fts_search!(search_scope, key: :q) + argument! key, :string do |arg| + arg.description <<~TEXT + Perform a full-text search to approximately match the provided string. + TEXT + end + + uses_scope! search_scope + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + before_ranking def rank_#{search_scope}! + augment_ranking! do |scope| + scope.#{search_scope}(#{key}).with_pg_search_rank if #{key}.present? + end + end + + after_build def apply_#{search_scope}! + augment_scope! do |scope| + scope.#{search_scope}(#{key}) if #{key}.present? + end + end + RUBY + end + + # @return [void] + def has_name_search! + fts_scope! :search_name, key: :name_search + end + + # Define a filter for matching integer columns. + # @param [Symbol] key + # @param [Symbol] column_name + # @return [void] + def integer_match!(key, column_name: key) + # :nocov: + argument! key, :integer_match do |arg| + arg.description <<~TEXT + Filter the model's `#{column_name}` with various integer constraints. + TEXT + end + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] + + augment_scope! do |scope| + scope.where(#{key}.(attribute)) if #{key}.present? + end + end + RUBY + # :nocov: + end + + # Define a nested filter for an associated model. This will use the filters + # generated by another {Filtering::FilterScope} for the associated model + # and allow you to filter by associations. + # + # @note In order to use this, you must first expose the associated model's + # filters within {Filtering::TypeContainer} under a key which is provided to `type_name`. + # + # @param [Symbol] association_name + # @param [Symbol] type_name + # @param [Symbol] key + # @yield [arg] a block to further configure the argument + # @yieldparam [Filtering::ArgumentBuilder] arg + # @yieldreturn [void] + # @return [void] + def nested_filter!(association_name, type_name: :"#{association_name}_filters", key: :"#{association_name}_filters", **options, &) + key = key.to_sym + + argument!(key, type_name, **options, &) + + uses_scope! :filter_by_nested + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_nested_#{association_name}_filters! + augment_scope! do |scope| + scope.filter_by_nested #{association_name.to_sym.inspect}, #{key} + end + end + RUBY + end + + # Define a simple equality filter for a column. + # + # @param [Symbol] key + # @param [Symbol] type_name + # @param [Symbol] column_name + # @yield [arg] a block to further configure the argument + # @yieldparam [Filtering::ArgumentBuilder] arg + # @yieldreturn [void] + # @return [void] + def simple_filter!(key, type_name, column_name: key, **options, &) + argument!(key, type_name, **options, &) + + column_name = column_name.to_sym + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + augment_scope! do |scope| + scope.where(#{column_name.inspect} => #{key}) unless #{key}.nil? || (#{key}.respond_to?(:empty?) && #{key}.empty?) + end + end + RUBY + end + + # Define a simple scope-based filter for a column. + # @param [Symbol] key + # @param [Symbol] type_name + # @param [Symbol] scope_name the name of the scope to call on the model, by default it will use a `lookup_by_#{key}` pattern + # which is created by {VOG::LookupHelpers} + # @yield [arg] a block to further configure the argument + # @yieldparam [Filtering::ArgumentBuilder] arg + # @yieldreturn [void] + # @return [void] + def simple_scope_filter!(key, type_name, scope_name: :"lookup_by_#{key}", **options, &) + argument!(key, type_name, **options, &) + + uses_scope! scope_name + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + augment_scope! do |scope| + scope.#{scope_name}(#{key}) unless #{key}.blank? + end + end + RUBY + end + + # Define a simple state filter for an enum column. + # + # @param [Symbol] enum_type the enum type to use for the argument + # @param [Symbol] key the argument key to use + # @param [Symbol] in_state_scope the name of the scope to call on the model + # @yield [arg] a block to further configure the argument + # @yieldparam [Filtering::ArgumentBuilder] arg + # @yieldreturn [void] + # @return [void] + def simple_state_filter!(enum_type, key: :in_state, in_state_scope: :in_state, **options, &) + argument!(key, enum_type, **options, &) + + uses_scope! in_state_scope + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + augment_scope! do |scope| + scope.#{in_state_scope}(#{key}) if #{key}.present? + end + end + RUBY + end + + # Define a simple truthy filter for a boolean column. + # + # @param [Symbol] key + # @param [Symbol] column_name + # @param [Boolean] filter_false whether to filter by false values when the argument is false + # @yield [arg] a block to further configure the argument + # @yieldparam [Filtering::ArgumentBuilder] arg + # @yieldreturn [void] + # @return [void] + def simple_truthy_filter!(key, column_name: key, filter_false: false, **options, &) + # :nocov: + argument!(key, :bool, **options, &) + + column_name = column_name.to_sym + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_truthy_#{key}! + augment_scope! do |scope| + if #{key} + scope.where(#{column_name.inspect} => true) + elsif #{key} == false && #{filter_false} + scope.where(#{column_name.inspect} => false) + end + end + end + RUBY + # :nocov: + end + + # @return [void] + def taggable! + argument! :external_tags, :tag_search do |arg| + arg.description "Search external tags." + end + + argument! :internal_tags, :tag_search do |arg| + arg.description "Search internal tags." + end + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build :apply_all_tags! + RUBY + end + + # Define a filter for matching time columns. + # + # @param [Symbol] key + # @param [Symbol] column_name + # @return [void] + def time_match!(key, column_name: key) + argument! key, :time_match do |arg| + arg.description <<~TEXT + Filter the model's `#{column_name}` with time constraints. + TEXT + end + + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + after_build def apply_#{key}! + attribute = self.class.model_klass.arel_table[#{column_name.to_sym.inspect}] + + augment_scope! do |scope| + scope.where(#{key}.(attribute)) if #{key}.present? + end + end + RUBY + end + + # Define filters for `created_at` and `updated_at` timestamp columns. + # @see #time_match! + # @return [void] + def timestamps! + time_match! :created_at + time_match! :updated_at + end + + # Define a filter for tracking mutations by users. + # + # @return [void] + def tracks_mutations! + # :nocov: + simple_scope_filter! :user, :users, scope_name: :touched_by_user do |arg| + arg.description "Filter by records that were created OR updated by these users." + end + # :nocov: + end + + # @api private + # @!attribute [r] arguments + # @return [Filtering::Arguments] the argument set for this filter scope + def arguments + @arguments ||= Filtering::Arguments.new + end + + # Duplicate the arguments for subclasses into a fresh {Filtering::Arguments} instance. + # + # @api private + # @param [Class(Filtering::HasArguments)] subclass + # @return [void] + def inherited(subclass) + super + + child_args = Filtering::Arguments.new.merge arguments + + subclass.instance_variable_set(:@arguments, child_args) + end + end + end + end +end diff --git a/lib/support/lib/filtering/default_scope.rb b/lib/support/lib/filtering/default_scope.rb new file mode 100644 index 00000000..7824a707 --- /dev/null +++ b/lib/support/lib/filtering/default_scope.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Support + module Filtering + # @abstract A further refined abstract scope that includes common argument helpers. + # This is the scope that the application's filter scope implementation should inherit from. + class DefaultScope < AbstractScope + include CommonArguments + end + end +end diff --git a/lib/support/lib/filtering/inputs/abstract_match.rb b/lib/support/lib/filtering/inputs/abstract_match.rb new file mode 100644 index 00000000..45f739c8 --- /dev/null +++ b/lib/support/lib/filtering/inputs/abstract_match.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Support + module Filtering + module Inputs + # The base class for building generic match input structs. + # + # @abstract + class AbstractMatch < ::Support::FlexibleStruct + extend Dry::Core::ClassAttributes + + include ArelHelpers + + # A type container for the comparator. + # + # @api private + BASE_TYPES = Support::DryGQL::TypeContainer.new + + defines :base_type, type: ::Support::Types::DryType + defines :type_key, type: ::Support::Types::Symbol + defines :input_object, type: ::Support::Types::Class.optional + + base_type ::Support::Types::Any + + input_object nil + + type_key :_unset + + private + + # @see ArelHelpers#arel_and_expressions + # @return [Arel::Expressions] + def arel_andify(...) + arel_and_expressions(...) + end + + class << self + # Build a new subclass for the given type key. + # + # Subclasses should inherit from the type generated by this method. + # + # @param [#to_s] type_key + # @return [Class(Filtering::Inputs::ComparatorMatch)] + def of(type_key, input_object: nil) + type = BASE_TYPES.resolve type_key + + base_class_name = "abstract_#{type_key}_match".classify + + Class.new(self).tap do |klass| + klass.base_type type + klass.type_key type_key + + Support::Filtering::Inputs.const_set base_class_name, klass + + klass.on_inherit! + + # :nocov: + klass.input_object input_object if input_object.present? + # :nocov: + end.with_gql_type + end + + # @param [Hash{Symbol => Object}] ruby_kwargs + # @return [GraphQL::Schema::InputObject] + def build_input_object(**ruby_kwargs) + input_object.new({}, ruby_kwargs:, context: nil, defaults_used: false) + end + + protected + + # @abstract + # @return [void] + def on_inherit!; end + + # Augment the subclass with GraphQL type information. + # + # @note Used within {.of}. + # @return [Class(Filtering::Inputs::AbstractMatch)] + def with_gql_type + gql_type(input_object) + end + end + end + end + end +end diff --git a/lib/support/lib/filtering/inputs/comparator_match.rb b/lib/support/lib/filtering/inputs/comparator_match.rb new file mode 100644 index 00000000..96718d51 --- /dev/null +++ b/lib/support/lib/filtering/inputs/comparator_match.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Support + module Filtering + module Inputs + # The base class for building comparator match input structs. + # + # @abstract + class ComparatorMatch < ::Support::Filtering::Inputs::AbstractMatch + include ArelHelpers + + # The available comparators. + COMPARATORS = %i[eq lt lteq gt gteq not_eq].freeze + + # @api private + # @return [{ Symbol => Object }] + attr_reader :comparators + + delegate :blank?, to: :comparators + + def initialize(...) + super + + @comparators = attributes.compact + end + + # @param [Arel::Attribute] attribute + # @return [Arel::Expressions] + def call(attribute) + return if blank? + + expressions = comparators.map do |(cmp, value)| + attribute.public_send(cmp, value) + end + + arel_andify expressions + end + + # @param [Symbol] cmp + def has?(cmp) + comparators.key? cmp + end + + alias has_comparator? has? + + class << self + protected + + # @return [void] + def on_inherit! + define_comparator_attributes_for! base_type + + input_object input_object_for(type_key) + end + + # @param [:date, :float, :integer, :time] type_key + # @return [Class(::Support::GQL::BaseFilterMatchInputObject)] + def input_object_for(type_key) + "::Support::GQL::FilterMatch#{type_key.to_s.classify}InputType".constantize + end + + # Decorate the subclass with comparator attributes. + # + # @param [Dry::Types::Type] type + # @return [void] + def define_comparator_attributes_for!(type) + COMPARATORS.each do |comparator| + attribute? comparator, type.optional + end + end + end + end + end + end +end diff --git a/lib/support/lib/filtering/inputs/date_match.rb b/lib/support/lib/filtering/inputs/date_match.rb new file mode 100644 index 00000000..1be47d24 --- /dev/null +++ b/lib/support/lib/filtering/inputs/date_match.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Support + module Filtering + module Inputs + # A comparator match input for date values. + # + # @see ::Support::Filtering::CommonArguments#date_match + class DateMatch < ::Support::Filtering::Inputs::ComparatorMatch.of(:date) + end + end + end +end diff --git a/lib/support/lib/filtering/inputs/float_match.rb b/lib/support/lib/filtering/inputs/float_match.rb new file mode 100644 index 00000000..b2cb8cde --- /dev/null +++ b/lib/support/lib/filtering/inputs/float_match.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Support + module Filtering + module Inputs + # A comparator match input for float values. + # + # @see ::Support::Filtering::CommonArguments#float_match + class FloatMatch < ::Support::Filtering::Inputs::ComparatorMatch.of(:float) + end + end + end +end diff --git a/lib/support/lib/filtering/inputs/integer_match.rb b/lib/support/lib/filtering/inputs/integer_match.rb new file mode 100644 index 00000000..c84f473b --- /dev/null +++ b/lib/support/lib/filtering/inputs/integer_match.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Support + module Filtering + module Inputs + # A comparator match input for integer values. + # + # @see ::Support::Filtering::CommonArguments#integer_match + class IntegerMatch < ::Support::Filtering::Inputs::ComparatorMatch.of(:integer) + end + end + end +end diff --git a/lib/support/lib/filtering/inputs/time_match.rb b/lib/support/lib/filtering/inputs/time_match.rb new file mode 100644 index 00000000..93663cb0 --- /dev/null +++ b/lib/support/lib/filtering/inputs/time_match.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Support + module Filtering + module Inputs + # A comparator match input for time values. + # + # @see ::Support::Filtering::CommonArguments#time_match + class TimeMatch < ::Support::Filtering::Inputs::ComparatorMatch.of(:time) + end + end + end +end diff --git a/lib/support/lib/filtering/runner.rb b/lib/support/lib/filtering/runner.rb new file mode 100644 index 00000000..c382290d --- /dev/null +++ b/lib/support/lib/filtering/runner.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Support + module Filtering + # A runner class that applies filtering scopes to ActiveRecord relations. + # + # @see Support::Filtering::Run + # @note Primarily used in testing. Use `Support::System["filtering.run"]` to execute. + class Runner + include Dry::Monads[:result] + include Dry::Initializer[undefined: false].define -> do + param :klass, Support::Models::Types::ModelClass + + option :base_scope, Types::Scope, default: proc { klass.all } + option :options, Types::FilterOptions + option :filter_klass, ::Support::Filtering::AbstractScope::Subclass, default: proc { "::Support::Filtering::Scopes::#{klass.model_name.to_s.pluralize}".constantize } + option :current_user, ::Support::Users::Types::Current, default: ::Support::Users::Types::DEFAULT_FROM_REQUEST + end + + # @return [Support::Filtering::FilterScope] + attr_reader :filters + + # @return [ActiveRecord::Relation] + def call + @filters = filter_klass.new(**options, current_user:) + + result = filters.apply_to base_scope + + Success result + end + end + end +end diff --git a/lib/support/lib/filtering/sets_struct_klass.rb b/lib/support/lib/filtering/sets_struct_klass.rb new file mode 100644 index 00000000..f3877382 --- /dev/null +++ b/lib/support/lib/filtering/sets_struct_klass.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Support + module Filtering + # A concern for GraphQL object types that set a filtering match struct class + module SetsStructKlass + extend ActiveSupport::Concern + + included do + extend Dry::Core::ClassAttributes + + defines :struct_klass_name, type: ::Support::Filtering::Types::String + + struct_klass_name "::Support::Filtering::Inputs::ComparatorMatch" + end + + private + + # @!attribute [r] struct_klass + # @return [Class(Support::Filtering::Inputs::ComparatorMatch)] + def struct_klass = self.class.struct_klass + + # Class methods for the including class. + # @api private + module ClassMethods + # @!attribute [r] struct_klass + # @!scope class + # @return [Class(Support::Filtering::Inputs::ComparatorMatch)] + def struct_klass + @struct_klass ||= struct_klass_name.constantize + end + end + end + end +end diff --git a/lib/support/lib/filtering/type_container.rb b/lib/support/lib/filtering/type_container.rb new file mode 100644 index 00000000..c9a3a545 --- /dev/null +++ b/lib/support/lib/filtering/type_container.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Support + module Filtering + class TypeContainer < Support::DryGQL::TypeContainer + add_model! "User" + + add_enum_type! ::Support::GQL::FullTextSearchStrategyType + + private + + # @return [void] + compile! def define_filter_inputs! + add! :full_text_search_query, ::Support::FullTextSearching::Query::Type + + add! :date_match, ::Support::Filtering::Inputs::DateMatch + + add! :float_match, ::Support::Filtering::Inputs::FloatMatch + + add! :integer_match, ::Support::Filtering::Inputs::IntegerMatch + + add! :time_match, ::Support::Filtering::Inputs::TimeMatch + end + end + end +end diff --git a/lib/support/lib/filtering/types.rb b/lib/support/lib/filtering/types.rb new file mode 100644 index 00000000..a719d5da --- /dev/null +++ b/lib/support/lib/filtering/types.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module Filtering + module Types + extend ::Support::Typespace + + # A type representing options that can be passed to a filter runner. + # + # @return [Dry::Types::Type] + FilterOptions = Hash.map(Coercible::Symbol, Any) + + # A type representing the input hash for filtering arguments. + # + # @return [Dry::Types::Type] + Input = Hash.fallback { Dry::Core::Constants::EMPTY_HASH } + + # A type representing an ActiveRecord scope / relation. + # + # @return [Dry::Types::Type] + Scope = ::Support::Types::Relation + + # A type representing the name of a filtering scope, which corresponds to a method on the model's ActiveRecord::Relation. + # + # @return [Dry::Types::Type] + ScopeName = Symbol + + # A list of {ScopeName}s. + # + # @return [Dry::Types::Type] + ScopeNames = Array.of(ScopeName) + end + end +end diff --git a/lib/support/lib/frozen_record_helpers/abstract_record.rb b/lib/support/lib/frozen_record_helpers/abstract_record.rb index e96dfcc5..6b94dd46 100644 --- a/lib/support/lib/frozen_record_helpers/abstract_record.rb +++ b/lib/support/lib/frozen_record_helpers/abstract_record.rb @@ -22,6 +22,7 @@ class AbstractRecord < FrozenRecord::Base defines :default_sql_values, type: Types::DefaultSQLValues defines :schema, type: Types::Schema.optional defines :sort_mapping, type: Types::Array.of(Types::String) + defines :type_registry, type: Types::TypeRegistry calculated_attributes EMPTY_HASH @@ -35,6 +36,8 @@ class AbstractRecord < FrozenRecord::Base scope :none, -> { where(_non_existing_: :match) } + type_registry Support::FrozenRecordHelpers::DefaultTypeRegistry + # @see #slice # @param [] keys # @return [{ Symbol => Object }] @@ -147,7 +150,7 @@ def extract_sort_mapping! end # @return [void] - def schema!(types: ::Shared::TypeRegistry, &block) + def schema!(types: type_registry, &block) defined = Dry::Schema.Params do config.types = types diff --git a/lib/support/lib/frozen_record_helpers/default_type_registry.rb b/lib/support/lib/frozen_record_helpers/default_type_registry.rb new file mode 100644 index 00000000..b82270fc --- /dev/null +++ b/lib/support/lib/frozen_record_helpers/default_type_registry.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Support + module FrozenRecordHelpers + DefaultTypeRegistry = Support::Schemas::TypeContainer.new + end +end diff --git a/lib/support/lib/frozen_record_helpers/types.rb b/lib/support/lib/frozen_record_helpers/types.rb index 9641b3b1..f5ebd375 100644 --- a/lib/support/lib/frozen_record_helpers/types.rb +++ b/lib/support/lib/frozen_record_helpers/types.rb @@ -12,6 +12,8 @@ module Types DefaultSQLValues = Array.of(Symbol) Schema = Nominal(Dry::Schema::Processor) + + TypeRegistry = Instance(Support::Schemas::TypeContainer) end end end diff --git a/lib/support/lib/full_text_searching/context.rb b/lib/support/lib/full_text_searching/context.rb new file mode 100644 index 00000000..2b8ded0c --- /dev/null +++ b/lib/support/lib/full_text_searching/context.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Support + module FullTextSearching + # Configuration for a full-text searching context. + # + # @api private + # @see ::HasFullTextSearching + class Context + include Support::Typing + include Dry::Core::Equalizer.new(:name) + include Dry::Initializer[undefined: false].define -> do + param :name, Types::ContextName + + option :columns, Types::ColumnNames + + option :default_strategy, Types::Strategy, default: -> { "fuzzy" } + + option :base_scope, Types::ScopeName, default: -> { :"search_#{name}" } + + option :exact_scope, Types::ScopeName, default: -> { :"search_#{name}_via_exact" } + + option :fuzzy_scope, Types::ScopeName, default: -> { :"search_#{name}_via_fuzzy" } + + option :prefix_scope, Types::ScopeName, default: -> { :"search_#{name}_via_prefix" } + + option :fuzzy_dictionary, Types::String, default: -> { "english" } + + option :prefix_dictionary, Types::String, default: -> { "simple" } + end + + map_type! key: Support::FullTextSearching::Types::ContextName + + COMMON_OPTIONS = { + ignoring: :accents, + }.freeze + + # @see https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-RANKING + DEFAULT_NORMALIZATION = 1 | 8 | 32 + + def initialize(...) + super + + @against = columns.map(&:to_sym) + + @fuzzy_options = build_fuzzy_options + @prefix_options = build_prefix_options + + @scope_module = ::Support::FullTextSearching::ScopeModule.new(self) + end + + # @return [] + attr_reader :against + + # @return [Hash] + attr_reader :fuzzy_options + + # @return [Hash] + attr_reader :prefix_options + + # @return [Searching::ScopeModule] + attr_reader :scope_module + + private + + # @return [Hash] + def build_common_options = COMMON_OPTIONS.deep_dup.merge(against:) + + # @return [Hash] + def build_fuzzy_options + trigram = build_trigram_options + tsearch = build_tsearch_options(websearch: true, dictionary: fuzzy_dictionary) + + using = { + trigram:, + tsearch:, + } + + build_common_options.merge( + using:, + ) + end + + # @return [Hash] + def build_prefix_options + trigram = build_trigram_options + tsearch = build_tsearch_options(prefix: true, dictionary: prefix_dictionary) + + using = { + trigram:, + tsearch:, + } + + build_common_options.merge( + using:, + ) + end + + # @return [Hash] + def build_trigram_options(word_similarity: true, **extra) + { + word_similarity:, + **extra + } + end + + # @return [Hash] + def build_tsearch_options(dictionary: "simple", normalization: DEFAULT_NORMALIZATION, **extra) + { + dictionary:, + normalization:, + **extra + } + end + end + end +end diff --git a/lib/support/lib/full_text_searching/matcher.rb b/lib/support/lib/full_text_searching/matcher.rb new file mode 100644 index 00000000..cf8afcc8 --- /dev/null +++ b/lib/support/lib/full_text_searching/matcher.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Support + module FullTextSearching + fuzzy = Dry::Matcher::Case.new do |value| + if value.fuzzy? && value.present? + value.needle + else + Dry::Matcher::Undefined + end + end + + prefix = Dry::Matcher::Case.new do |value| + if value.prefix? && value.present? + value.needle + else + Dry::Matcher::Undefined + end + end + + exact = Dry::Matcher::Case.new do |value| + if value.exact? && value.present? + value.needle + else + Dry::Matcher::Undefined + end + end + + empty = Dry::Matcher::Case.new do |value| + if value.empty? + nil + else + Dry::Matcher::Undefined + end + end + + # A matcher for determing the appropriate search strategy to use, + # based on the properties of a {::Support::FullTextSearching::Query} instance. + # + # @api private + # @see ::Support::FullTextSearching::Query + Matcher = Dry::Matcher.new(exact:, fuzzy:, prefix:, empty:) + end +end diff --git a/lib/support/lib/full_text_searching/query.rb b/lib/support/lib/full_text_searching/query.rb new file mode 100644 index 00000000..0a3c3585 --- /dev/null +++ b/lib/support/lib/full_text_searching/query.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Support + module FullTextSearching + # A struct representing the parameters for a full-text search query. + # + # @see ::Support::GQL::FullTextSearchStrategyType + # @see ::Support::GQL::FullTextSearchQueryInputType + class Query < Support::FlexibleStruct + include Dry::Matcher.for(:apply, with: FullTextSearching::Matcher) + include Support::Typing + + with_gql_type! ::Support::GQL::FullTextSearchQueryInputType + + attribute :needle, Types::Needle + + attribute? :strategy, Types::Strategy + + def apply = self + + def empty? = needle.blank? + + def exact? = strategy == "exact" + + def fuzzy? = strategy == "fuzzy" + + def prefix? = strategy == "prefix" + + def ranked_by_relevance? = present? && (fuzzy? || prefix?) + + class << self + # @param [FullTextSearching::Query, String, Hash] input the input to convert to a `FullTextSearching::Query` instance + # @param ["fuzzy", "prefix"] strategy the default search strategy to use when the input is a string or hash without a specified strategy + # @return [FullTextSearching::Query] a `FullTextSearching::Query` instance representing the provided input + def from(input, strategy: "fuzzy") + case input + when self then input + when String then new(needle: input, strategy:) + when Hash then new(strategy:, **input) + else + raise ArgumentError, "Cannot convert #{input.inspect} to #{self}" + end + end + end + end + end +end diff --git a/lib/support/lib/full_text_searching/scope_module.rb b/lib/support/lib/full_text_searching/scope_module.rb new file mode 100644 index 00000000..d138cbee --- /dev/null +++ b/lib/support/lib/full_text_searching/scope_module.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Support + module FullTextSearching + # @api private + # @see ::Support::FullTextSearching::Context + class ScopeModule < Module + # @return [::Support::FullTextSearching::Context] + attr_reader :context + + # @param [::Support::FullTextSearching::Context] context + def initialize(context) + @context = context + + define_base_scope! + end + + # @param [Class] base + # @return [void] + def extended(base) + ctx = context + + base.scope context.exact_scope, ->(needle) do + expressions = arel_or_expressions ctx.columns do |column| + arel_table[column].eq(needle) + end + + where(arel_grouping(expressions)) + end + + base.pg_search_scope(context.fuzzy_scope, context.fuzzy_options) + base.pg_search_scope(context.prefix_scope, context.prefix_options) + end + + private + + def define_base_scope! + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + # @param [String, ::Support::FullTextSearching::Query] raw_query the search query to match against the `name` attribute + # @param ["fuzzy", "prefix"] strategy the default search strategy to use, either "prefix" for prefix matching or "fuzzy" for websearch-style FTS matching + # @return [ActiveRecord::Relation] a relation of records matching the search query + def #{context.base_scope}(raw_query, strategy: #{context.default_strategy.inspect}) + query = ::Support::FullTextSearching::Query.from(raw_query, strategy:) + + query.apply do |m| + m.exact { |needle| #{context.exact_scope}(needle) } + m.fuzzy { |needle| #{context.fuzzy_scope}(needle) } + m.prefix { |needle| #{context.prefix_scope}(needle) } + m.empty { all } + end + end + RUBY + end + end + end +end diff --git a/lib/support/lib/full_text_searching/types.rb b/lib/support/lib/full_text_searching/types.rb new file mode 100644 index 00000000..356a31cf --- /dev/null +++ b/lib/support/lib/full_text_searching/types.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Support + module FullTextSearching + module Types + extend Support::Typespace + + Attribute = Coercible::Symbol.constrained(format: /\A[a-z_][a-z0-9_]*\z/) + + ColumnName = Attribute + + ColumnNames = Types::Array.of(ColumnName).constrained(min_size: 1) + + ContextName = Attribute + + Needle = Coercible::String.optional + + ScopeName = Attribute + + # @see Support::GQL::SearchStrategyType + Strategy = Coercible::String.enum("exact", "fuzzy", "prefix").fallback("fuzzy") + end + end +end diff --git a/lib/support/lib/gql/abstract_object.rb b/lib/support/lib/gql/abstract_object.rb new file mode 100644 index 00000000..fdb965d2 --- /dev/null +++ b/lib/support/lib/gql/abstract_object.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract A base type for all VOG GraphQL object-likes that ultimately inherit from `GraphQL::Schema::Object`. + class AbstractObject < ::GraphQL::Schema::Object + include ::Support::CallsCommonOperation + include ::Support::GraphQLAPI::Enhancements::AbstractObject + + include ::GraphQL::FragmentCache::Object + + def current_user_privileged? + context[:current_user].try(:has_global_admin_access?) + end + end + end +end diff --git a/lib/support/lib/gql/base_argument.rb b/lib/support/lib/gql/base_argument.rb new file mode 100644 index 00000000..3a90b3dd --- /dev/null +++ b/lib/support/lib/gql/base_argument.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseArgument < ::GraphQL::Schema::Argument + # @abstract + def initialize(*args, attribute: true, transient: false, replace_null_with_default: nil, **kwargs, &block) + @attribute = attribute + @transient = transient + + replace_null_with_default = !kwargs[:default_value].nil? if replace_null_with_default.nil? + + super(*args, replace_null_with_default:, **kwargs, &block) + end + + def attribute? = @attribute.present? + + # @return [] + def attribute_names(names: [], parent: nil) = argument_paths_for_if(&:attribute?) + + # @param [] names + # @param [String, nil] parent + # @yield [arg] + # @yieldparam [Types::BaseArgument] arg + # @yieldreturn [Boolean] + # @return [] + def argument_paths_for_if(names: [], parent: nil, &block) + argument_name = [parent, keyword || name].compact.join(?.) + + names << argument_name if yield(self) + + nested_arguments.each_with_object(names) do |arg, n| + names += arg.argument_paths_for_if(names: n, parent: argument_name, &block) if yield(arg) + end + end + + # @api private + # @return [] + def nested_arguments + if type.respond_to?(:arguments) + type.arguments.values + elsif type.respond_to?(:of_type) && type.of_type.respond_to?(:arguments) + type.of_type.arguments.values + else + [] + end + end + + # @return [] + def transient_arguments(names: [], parent: nil) + argument_paths_for_if(&:transient?).map do |arg| + arg.split(?.).map { _1.to_s.underscore }.join(?.).to_sym + end + end + + def transient? = @transient + end + end +end diff --git a/lib/support/lib/gql/base_connection.rb b/lib/support/lib/gql/base_connection.rb new file mode 100644 index 00000000..6f98acdf --- /dev/null +++ b/lib/support/lib/gql/base_connection.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseConnection < ::Support::GQL::AbstractObject + # add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides + include GraphQL::Types::Relay::ConnectionBehaviors + + implements Support::GQL::PaginatedType + + # Override built-in pageInfo field type with our type, supporting page-based pagination + get_field("pageInfo").type = GraphQL::Schema::Member::BuildType.parse_type(Support::GQL::PageInfoType, null: false) + + edge_nullable false + node_nullable false + edges_nullable false + end + end +end diff --git a/lib/support/lib/gql/base_edge.rb b/lib/support/lib/gql/base_edge.rb new file mode 100644 index 00000000..f2ce9d89 --- /dev/null +++ b/lib/support/lib/gql/base_edge.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseEdge < ::Support::GQL::AbstractObject + # add `node` and `cursor` fields, as well as `node_type(...)` override + include ::GraphQL::Types::Relay::EdgeBehaviors + + node_nullable false + end + end +end diff --git a/lib/support/lib/gql/base_enum.rb b/lib/support/lib/gql/base_enum.rb new file mode 100644 index 00000000..d0852187 --- /dev/null +++ b/lib/support/lib/gql/base_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseEnum < ::GraphQL::Schema::Enum + include ::Support::GraphQLAPI::Enhancements::Enum + end + end +end diff --git a/lib/support/lib/gql/base_field.rb b/lib/support/lib/gql/base_field.rb new file mode 100644 index 00000000..db52e6b5 --- /dev/null +++ b/lib/support/lib/gql/base_field.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseField < ::GraphQL::Schema::Field + prepend ::ActionPolicy::GraphQL::AuthorizedField + + argument_class ::Support::GQL::BaseArgument + end + end +end diff --git a/lib/support/lib/gql/base_filter_match_input_object.rb b/lib/support/lib/gql/base_filter_match_input_object.rb new file mode 100644 index 00000000..637a6ec6 --- /dev/null +++ b/lib/support/lib/gql/base_filter_match_input_object.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract For input objects that represent filtering options. + # @see Support::Filtering::Inputs::ComparatorMatch + class BaseFilterMatchInputObject < ::Support::GQL::BaseInputObject + include ::Support::Filtering::SetsStructKlass + + description <<~TEXT + Filter a value with various constraints. If no values are provided to any + operator, this filter will be ignored. + + **Note**: The server will _not_ try to check for logical impossibilities, + e.g. `{ lt: 5, gteq: 10 }`. Input like this will match nothing. + TEXT + + # @return [Support::Filtering::Inputs::ComparatorMatch, nil] + def prepare = struct_klass.new(to_h).presence + end + end +end diff --git a/lib/support/lib/gql/base_filter_match_object.rb b/lib/support/lib/gql/base_filter_match_object.rb new file mode 100644 index 00000000..95ac5cae --- /dev/null +++ b/lib/support/lib/gql/base_filter_match_object.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract For input objects that represent filtering options. + # @see Support::Filtering::Inputs::ComparatorMatch + class BaseFilterMatchObject < ::Support::GQL::BaseObject + include ::Support::Filtering::SetsStructKlass + + description <<~TEXT + A value with various constraints. If no values are provided to any + operator, this filter will be ignored. + TEXT + end + end +end diff --git a/lib/support/lib/gql/base_filter_scope_input_object.rb b/lib/support/lib/gql/base_filter_scope_input_object.rb new file mode 100644 index 00000000..181ec902 --- /dev/null +++ b/lib/support/lib/gql/base_filter_scope_input_object.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract For input objects that represent filtering options. + # @see Support::Filtering::DefaultScope + class BaseFilterScopeInputObject < Support::GQL::BaseInputObject + # @return [Support::Filtering::DefaultScope, nil] + def prepare + options = to_h.symbolize_keys.compact.presence + + # :nocov: + return nil if options.nil? + # :nocov: + + self.class.filter_scope.new(**options) + end + + class << self + # @return [Class(Support::Filtering::DefaultScope)] + attr_reader :filter_scope + + # @param [Class(Support::Filtering::DefaultScope)] filter_scope + # @return [void] + def inherit_from!(filter_scope) + @filter_scope = filter_scope + end + end + end + end +end diff --git a/lib/support/lib/gql/base_input_object.rb b/lib/support/lib/gql/base_input_object.rb new file mode 100644 index 00000000..834d0466 --- /dev/null +++ b/lib/support/lib/gql/base_input_object.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseInputObject < ::GraphQL::Schema::InputObject + argument_class ::Support::GQL::BaseArgument + + class << self + # @param [String, Symbol] name + # @return [Boolean] whether this input object has an argument with the given name + def has_argument_named?(name) = arguments.key?(name.to_s) + end + end + end +end diff --git a/lib/support/lib/gql/base_interface.rb b/lib/support/lib/gql/base_interface.rb new file mode 100644 index 00000000..00df8e45 --- /dev/null +++ b/lib/support/lib/gql/base_interface.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + module BaseInterface + extend ::Support::GraphQLAPI::Enhancements::Interface + + edge_type_class ::Support::GQL::BaseEdge + connection_type_class ::Support::GQL::BaseConnection + + field_class ::Support::GQL::BaseField + end + end +end diff --git a/lib/support/lib/gql/base_object.rb b/lib/support/lib/gql/base_object.rb new file mode 100644 index 00000000..611ebc5e --- /dev/null +++ b/lib/support/lib/gql/base_object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseObject < ::Support::GQL::AbstractObject + edge_type_class ::Support::GQL::BaseEdge + + connection_type_class ::Support::GQL::BaseConnection + + field_class ::Support::GQL::BaseField + end + end +end diff --git a/lib/support/lib/gql/base_scalar.rb b/lib/support/lib/gql/base_scalar.rb new file mode 100644 index 00000000..56154ca1 --- /dev/null +++ b/lib/support/lib/gql/base_scalar.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseScalar < ::GraphQL::Schema::Scalar + extend Dry::Core::ClassAttributes + + # @api private + module DryScalar + extend ActiveSupport::Concern + + included do + defines :dry_type, type: ::Support::Types::DryType + end + + module ClassMethods + # @param [Object] input_value + # @raise [GraphQL::ExecutionError] If an invalid input value is provided, return a top-level GQL execution error. + # @return [Object] + def coerce_input(input_value, _) + dry_type[input_value] + rescue Dry::Types::ConstraintError => e + raise GraphQL::ExecutionError, "Invalid Scalar Input: #{e.message}" + end + + # @param [Object] ruby_value + # @raise [Dry::Types::ConstraintError] we should raise this at runtime + # because we should never be sending invalid values through the API. + # @return [Object] + def coerce_result(ruby_value, _) + dry_type[ruby_value] + end + end + end + + class << self + # Set up a scalar to wrap around a dry-type for validation. + # + # @param [Dry::Types::Type] type + # @return [void] + def wraps_dry_type!(type) + include DryScalar + + dry_type type + end + end + end + end +end diff --git a/lib/support/lib/gql/base_union.rb b/lib/support/lib/gql/base_union.rb new file mode 100644 index 00000000..3a1b6e1e --- /dev/null +++ b/lib/support/lib/gql/base_union.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Support + module GQL + # @abstract + class BaseUnion < ::GraphQL::Schema::Union + edge_type_class ::Support::GQL::BaseEdge + + connection_type_class ::Support::GQL::BaseConnection + end + end +end diff --git a/lib/support/lib/gql/common_model_type.rb b/lib/support/lib/gql/common_model_type.rb new file mode 100644 index 00000000..e0bacc44 --- /dev/null +++ b/lib/support/lib/gql/common_model_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Support + module GQL + # A consolidated interface for a model that implements a number of other interfaces. + module CommonModelType + include Support::GQL::BaseInterface + + implements ::GraphQL::Types::Relay::Node + + implements ::Support::GQL::CommonPermissionsType + implements ::Support::GQL::HasDefaultTimestampsType + implements ::Support::GQL::SluggableType + end + end +end diff --git a/lib/support/lib/gql/common_permissions_type.rb b/lib/support/lib/gql/common_permissions_type.rb new file mode 100644 index 00000000..b0a26fa5 --- /dev/null +++ b/lib/support/lib/gql/common_permissions_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Support + module GQL + # An interface that specifies that a record has some common permissions. + module CommonPermissionsType + include Support::GQL::BaseInterface + + description <<~TEXT + Common permissions shared on most models. + TEXT + + expose_authorization_rule :update?, <<~TEXT + Whether the current user has permission to update this record. + TEXT + + expose_authorization_rule :destroy?, <<~TEXT + Whether the current user has permission to destroy this record. + TEXT + end + end +end diff --git a/lib/support/lib/gql/filter_match_date_input_type.rb b/lib/support/lib/gql/filter_match_date_input_type.rb new file mode 100644 index 00000000..754adbac --- /dev/null +++ b/lib/support/lib/gql/filter_match_date_input_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Support::Filtering::Inputs::DateMatch + class FilterMatchDateInputType < ::Support::GQL::BaseFilterMatchInputObject + struct_klass_name "Support::Filtering::Inputs::DateMatch" + + argument :eq, ::GraphQL::Types::ISO8601Date, required: false do + description "Value to compare with using the `eq` operator." + end + + argument :not_eq, ::GraphQL::Types::ISO8601Date, required: false do + description "Value to compare with using the `not_eq` operator." + end + + argument :lt, ::GraphQL::Types::ISO8601Date, required: false do + description "Value to compare with using the `lt` operator." + end + + argument :lteq, ::GraphQL::Types::ISO8601Date, required: false do + description "Value to compare with using the `lteq` operator." + end + + argument :gt, ::GraphQL::Types::ISO8601Date, required: false do + description "Value to compare with using the `gt` operator." + end + + argument :gteq, ::GraphQL::Types::ISO8601Date, required: false do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/filter_match_date_type.rb b/lib/support/lib/gql/filter_match_date_type.rb new file mode 100644 index 00000000..8ee38349 --- /dev/null +++ b/lib/support/lib/gql/filter_match_date_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Support::Filtering::Inputs::DateMatch + class FilterMatchDateType < ::Support::GQL::BaseFilterMatchObject + struct_klass_name "Support::Filtering::Inputs::DateMatch" + + field :eq, ::GraphQL::Types::ISO8601Date, null: true do + description "Value to compare with using the `eq` operator." + end + + field :not_eq, ::GraphQL::Types::ISO8601Date, null: true do + description "Value to compare with using the `not_eq` operator." + end + + field :lt, ::GraphQL::Types::ISO8601Date, null: true do + description "Value to compare with using the `lt` operator." + end + + field :lteq, ::GraphQL::Types::ISO8601Date, null: true do + description "Value to compare with using the `lteq` operator." + end + + field :gt, ::GraphQL::Types::ISO8601Date, null: true do + description "Value to compare with using the `gt` operator." + end + + field :gteq, ::GraphQL::Types::ISO8601Date, null: true do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/filter_match_float_input_type.rb b/lib/support/lib/gql/filter_match_float_input_type.rb new file mode 100644 index 00000000..9c64ab4f --- /dev/null +++ b/lib/support/lib/gql/filter_match_float_input_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Support::Filtering::Inputs::FloatMatch + class FilterMatchFloatInputType < ::Support::GQL::BaseFilterMatchInputObject + struct_klass_name "Support::Filtering::Inputs::FloatMatch" + + argument :eq, Float, required: false do + description "Value to compare with using the `eq` operator." + end + + argument :not_eq, Float, required: false do + description "Value to compare with using the `not_eq` operator." + end + + argument :lt, Float, required: false do + description "Value to compare with using the `lt` operator." + end + + argument :lteq, Float, required: false do + description "Value to compare with using the `lteq` operator." + end + + argument :gt, Float, required: false do + description "Value to compare with using the `gt` operator." + end + + argument :gteq, Float, required: false do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/filter_match_float_type.rb b/lib/support/lib/gql/filter_match_float_type.rb new file mode 100644 index 00000000..fae8be6a --- /dev/null +++ b/lib/support/lib/gql/filter_match_float_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Support::Filtering::Inputs::FloatMatch + class FilterMatchFloatType < ::Support::GQL::BaseFilterMatchObject + struct_klass_name "Support::Filtering::Inputs::FloatMatch" + + field :eq, GraphQL::Types::Float, null: true do + description "Value to compare with using the `eq` operator." + end + + field :not_eq, GraphQL::Types::Float, null: true do + description "Value to compare with using the `not_eq` operator." + end + + field :lt, GraphQL::Types::Float, null: true do + description "Value to compare with using the `lt` operator." + end + + field :lteq, GraphQL::Types::Float, null: true do + description "Value to compare with using the `lteq` operator." + end + + field :gt, GraphQL::Types::Float, null: true do + description "Value to compare with using the `gt` operator." + end + + field :gteq, GraphQL::Types::Float, null: true do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/filter_match_integer_input_type.rb b/lib/support/lib/gql/filter_match_integer_input_type.rb new file mode 100644 index 00000000..194937b5 --- /dev/null +++ b/lib/support/lib/gql/filter_match_integer_input_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Support::Filtering::Inputs::IntegerMatch + class FilterMatchIntegerInputType < ::Support::GQL::BaseFilterMatchInputObject + struct_klass_name "Support::Filtering::Inputs::IntegerMatch" + + argument :eq, Int, required: false do + description "Value to compare with using the `eq` operator." + end + + argument :not_eq, Int, required: false do + description "Value to compare with using the `not_eq` operator." + end + + argument :lt, Int, required: false do + description "Value to compare with using the `lt` operator." + end + + argument :lteq, Int, required: false do + description "Value to compare with using the `lteq` operator." + end + + argument :gt, Int, required: false do + description "Value to compare with using the `gt` operator." + end + + argument :gteq, Int, required: false do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/filter_match_integer_type.rb b/lib/support/lib/gql/filter_match_integer_type.rb new file mode 100644 index 00000000..e4b4cebb --- /dev/null +++ b/lib/support/lib/gql/filter_match_integer_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Support::Filtering::Inputs::IntegerMatch + class FilterMatchIntegerType < ::Support::GQL::BaseFilterMatchObject + struct_klass_name "Support::Filtering::Inputs::IntegerMatch" + + field :eq, GraphQL::Types::Int, null: true do + description "Value to compare with using the `eq` operator." + end + + field :not_eq, GraphQL::Types::Int, null: true do + description "Value to compare with using the `not_eq` operator." + end + + field :lt, GraphQL::Types::Int, null: true do + description "Value to compare with using the `lt` operator." + end + + field :lteq, GraphQL::Types::Int, null: true do + description "Value to compare with using the `lteq` operator." + end + + field :gt, GraphQL::Types::Int, null: true do + description "Value to compare with using the `gt` operator." + end + + field :gteq, GraphQL::Types::Int, null: true do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/filter_match_time_input_type.rb b/lib/support/lib/gql/filter_match_time_input_type.rb new file mode 100644 index 00000000..dfa1fe94 --- /dev/null +++ b/lib/support/lib/gql/filter_match_time_input_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Support::Filtering::Inputs::TimeMatch + class FilterMatchTimeInputType < ::Support::GQL::BaseFilterMatchInputObject + struct_klass_name "Support::Filtering::Inputs::TimeMatch" + + argument :eq, GraphQL::Types::ISO8601DateTime, required: false do + description "Value to compare with using the `eq` operator." + end + + argument :not_eq, GraphQL::Types::ISO8601DateTime, required: false do + description "Value to compare with using the `not_eq` operator." + end + + argument :lt, GraphQL::Types::ISO8601DateTime, required: false do + description "Value to compare with using the `lt` operator." + end + + argument :lteq, GraphQL::Types::ISO8601DateTime, required: false do + description "Value to compare with using the `lteq` operator." + end + + argument :gt, GraphQL::Types::ISO8601DateTime, required: false do + description "Value to compare with using the `gt` operator." + end + + argument :gteq, GraphQL::Types::ISO8601DateTime, required: false do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/filter_match_time_type.rb b/lib/support/lib/gql/filter_match_time_type.rb new file mode 100644 index 00000000..1f4f820f --- /dev/null +++ b/lib/support/lib/gql/filter_match_time_type.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see ::Filtering::Inputs::TimeMatch + class FilterMatchTimeType < ::Support::GQL::BaseFilterMatchObject + struct_klass_name "Filtering::Inputs::TimeMatch" + + field :eq, GraphQL::Types::ISO8601DateTime, null: true do + description "Value to compare with using the `eq` operator." + end + + field :not_eq, GraphQL::Types::ISO8601DateTime, null: true do + description "Value to compare with using the `not_eq` operator." + end + + field :lt, GraphQL::Types::ISO8601DateTime, null: true do + description "Value to compare with using the `lt` operator." + end + + field :lteq, GraphQL::Types::ISO8601DateTime, null: true do + description "Value to compare with using the `lteq` operator." + end + + field :gt, GraphQL::Types::ISO8601DateTime, null: true do + description "Value to compare with using the `gt` operator." + end + + field :gteq, GraphQL::Types::ISO8601DateTime, null: true do + description "Value to compare with using the `gteq` operator." + end + end + end +end diff --git a/lib/support/lib/gql/full_text_search_query_input_type.rb b/lib/support/lib/gql/full_text_search_query_input_type.rb new file mode 100644 index 00000000..3a05296e --- /dev/null +++ b/lib/support/lib/gql/full_text_search_query_input_type.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Support + module GQL + # @see Support::FullTextSearching::Query + class FullTextSearchQueryInputType < ::Support::GQL::BaseInputObject + description <<~TEXT + An input object representing the parameters for a full-text search query. + TEXT + + argument :needle, String, required: false do + description <<~TEXT + The query to search by. + TEXT + end + + argument :strategy, ::Support::GQL::FullTextSearchStrategyType, required: false, default_value: "fuzzy", replace_null_with_default: true do + description <<~TEXT + The search strategy to use, either "PREFIX" for prefix matching or "FUZZY" for fuzzy query matching. + TEXT + end + + # @return [Support::FullTextSearching::Query] + def prepare = ::Support::FullTextSearching::Query.from(to_h) + end + end +end diff --git a/lib/support/lib/gql/full_text_search_strategy_type.rb b/lib/support/lib/gql/full_text_search_strategy_type.rb new file mode 100644 index 00000000..cf8c55de --- /dev/null +++ b/lib/support/lib/gql/full_text_search_strategy_type.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Support + module GQL + class FullTextSearchStrategyType < ::Support::GQL::BaseEnum + description <<~TEXT + The strategy to use for full-text search queries. + TEXT + + value "EXACT", value: "exact" do + description <<~TEXT + This will look for an exact match of the provided needle. + TEXT + end + + value "FUZZY", value: "fuzzy" do + description <<~TEXT + This uses a "fuzzy" full-text websearch strategy, + which supports using quotation marks and negation. + TEXT + end + + value "PREFIX", value: "prefix" do + description <<~TEXT + This will try to match beginnings of words in the provided needle. + TEXT + end + end + end +end diff --git a/lib/support/lib/gql/has_default_timestamps_type.rb b/lib/support/lib/gql/has_default_timestamps_type.rb new file mode 100644 index 00000000..4f63dc51 --- /dev/null +++ b/lib/support/lib/gql/has_default_timestamps_type.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Support + module GQL + # @note This interface exists to be able to DRY up adding timestamps + # to types that do not inherit from {Support::GQL::CommonModelType}. + module HasDefaultTimestampsType + include Support::GQL::BaseInterface + + description <<~TEXT + Automatically-set timestamps present on most real models in the system. + TEXT + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false do + description "The date this record was created within the API." + end + + field :created_on, GraphQL::Types::ISO8601Date, null: false do + description "The date this record was created within the API (date only)." + end + + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false do + description "The date this record was last updated within the API." + end + + field :updated_on, GraphQL::Types::ISO8601Date, null: false do + description "The date this record was last updated within the API (date only)." + end + + def created_on = object.created_at.to_date + + def updated_on = object.updated_at.to_date + end + end +end diff --git a/lib/support/lib/gql/page_direction_type.rb b/lib/support/lib/gql/page_direction_type.rb new file mode 100644 index 00000000..524f7023 --- /dev/null +++ b/lib/support/lib/gql/page_direction_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Support + module GQL + class PageDirectionType < ::Support::GQL::BaseEnum + description <<~TEXT + Determines the direction that page-number based pagination should flow + TEXT + + value "FORWARDS", value: :forwards do + description <<~TEXT + Indicates that page-number based pagination should flow in ascending order (1-9) + TEXT + end + + value "BACKWARDS", value: :backwards do + description <<~TEXT + Indicates that page-number based pagination should flow in descending order (9-1) + TEXT + end + end + end +end diff --git a/lib/support/lib/gql/page_info_type.rb b/lib/support/lib/gql/page_info_type.rb new file mode 100644 index 00000000..447c8a84 --- /dev/null +++ b/lib/support/lib/gql/page_info_type.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Support + module GQL + # An override of the default Relay PageInfo type to add additional fields + # for page-based pagination. + class PageInfoType < Support::GQL::AbstractObject + include GraphQL::Types::Relay::PageInfoBehaviors + + field :page, Integer, null: true do + description "The page (if page-based pagination is supported and one was provided, does not introspect a value with cursor-based pagination)" + end + + field :page_count, Integer, null: true do + description "The total number of pages available to the connection (if page-based pagination supported and a page was provided)" + end + + field :per_page, Integer, null: true do + description "The number of edges/nodes per page (if page-based pagination supported and a page was provided)" + end + + field :total_count, Integer, null: false do + description "The total number of nodes available to this connection, constrained by applied filters (if any)" + end + + field :total_unfiltered_count, Integer, null: false do + description "The total number of nodes available to this connection, independent of any filters" + end + + # @return [Integer, nil] + def page + from_info :page + end + + # @return [Integer, nil] + def page_count + # :nocov: + return nil if per_page.nil? + # :nocov: + + size = total_count + + return 0 if size.zero? + + full_pages, rem = size.divmod per_page + + rem > 0 ? full_pages + 1 : full_pages + end + + # @return [Integer, nil] + def per_page + from_info :per_page + end + + # @return [Integer] + def total_count + from_connection_info(:total_count) do + object.items.count_from_subquery + end + end + + # @return [Integer] + def total_unfiltered_count + from_connection_info(:unfiltered_count) do + from_resolver(:unfiltered_count) { total_count } + end + end + + private + + # @param [Symbol] key + # @return [Object] + def from_connection_info(key) + object.context[:connection_info].then do |cinfo| + # :nocov: + cinfo&.__send__(key) || yield + # :nocov: + end + end + + # @param [Symbol] key + # @return [Object] + def from_info(key) + object.context[:pagination].then do |pagination| + if pagination.kind_of?(Hash) + pagination[key] + else + # :nocov: + object.arguments[key] + # :nocov: + end + end + end + + # @param [Symbol] method_name + # @return [Object] + def from_resolver(method_name, &) + object.context[:resolver].try(method_name).then do |value| + value.presence || yield + end + end + end + end +end diff --git a/lib/support/lib/gql/paginated_type.rb b/lib/support/lib/gql/paginated_type.rb new file mode 100644 index 00000000..5bc697bf --- /dev/null +++ b/lib/support/lib/gql/paginated_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Support + module GQL + module PaginatedType + include ::Support::GQL::BaseInterface + + description <<~TEXT + Connections can be paginated by cursor or number. + TEXT + + field :page_info, ::Support::GQL::PageInfoType, null: false do + description <<~TEXT + Information to aid in pagination. + TEXT + end + end + end +end diff --git a/lib/support/lib/gql/simple_order_type.rb b/lib/support/lib/gql/simple_order_type.rb new file mode 100644 index 00000000..230b8457 --- /dev/null +++ b/lib/support/lib/gql/simple_order_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Support + module GQL + class SimpleOrderType < Support::GQL::BaseEnum + description <<~TEXT + A generic enum for sorting models that don't have anything more specific implemented. + TEXT + + value "RECENT" do + description <<~TEXT + Sort models by newest created date. + TEXT + end + + value "OLDEST" do + description <<~TEXT + Sort models by oldest created date. + TEXT + end + end + end +end diff --git a/lib/support/lib/gql/slug_type.rb b/lib/support/lib/gql/slug_type.rb new file mode 100644 index 00000000..c944da89 --- /dev/null +++ b/lib/support/lib/gql/slug_type.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Support + module GQL + # A slug that can identify a record in context. + # + # It represents an encoded primary key. + # + # @todo Provide a hook in order to move the schema slug parsing out of `Support`. + class SlugType < Support::GQL::BaseScalar + description <<~TEXT + A slug that can identify a record in context. + TEXT + + SCHEMA_SLUG_PATTERN = / + \A(?:[a-z_]+):(?:[a-z_]+)(?::[^:]+)?\z + /x + + class << self + def coerce_input(input_value, context) + case input_value + when SCHEMA_SLUG_PATTERN then input_value + when AnonymousUser::ID then AnonymousUser::ID + else + Support::System["slugs.decode_id"].call(input_value).value_or(nil) + end + end + + def coerce_result(ruby_value, context) + case ruby_value + when SCHEMA_SLUG_PATTERN then ruby_value + when AnonymousUser::ID then AnonymousUser::ID + else + Support::System["slugs.encode_id"].call(ruby_value).value_or(nil) + end + end + end + end + end +end diff --git a/lib/support/lib/gql/sluggable_type.rb b/lib/support/lib/gql/sluggable_type.rb new file mode 100644 index 00000000..7676dc8b --- /dev/null +++ b/lib/support/lib/gql/sluggable_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Support + module GQL + # An interface that specifies that a record can be looked up via {Support::GQL::SlugType}. + module SluggableType + include Support::GQL::BaseInterface + + description <<~TEXT + Objects have a serialized slug for looking them up in the system and generating links without UUIDs. + TEXT + + field :slug, Support::GQL::SlugType, null: false do + description <<~TEXT + The encoded slug for this record. + TEXT + end + + # @note This value will be parsed by {Support::GQL::SlugType} to encode the primary key. + # @return [String] + def slug = object.id + end + end +end diff --git a/lib/support/lib/graphql_api/base_model_interface.rb b/lib/support/lib/graphql_api/base_model_interface.rb new file mode 100644 index 00000000..5a8f362c --- /dev/null +++ b/lib/support/lib/graphql_api/base_model_interface.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Support + module GraphQLAPI + module BaseModelInterface + extend ActiveSupport::Concern + + included do + implements ::Support::GQL::CommonModelType + + global_id_field :id + end + + module ClassMethods + # @param [ApplicationRecord] object + # @param [GraphQL::Query::Context] graphql_context + # @raise [ActionPolicy::NotFound] if a policy cannot be found for the object + def authorized?(object, graphql_context) + context = { user: graphql_context[:current_user], } + + if graphql_context[:current_object].kind_of?(::Types::MutationType) + # This is an object being loaded as an argument in a mutation. + # If we can't even read it, throw an exception that GQL will catch and skip the mutation entirely. + return authorize!(object, to: :read_for_mutation?, context:) + end + + allowed_to?(:show?, object, context:) + end + + def inherited(subclass) + super + + subclass.global_id_field :id + end + end + end + end +end diff --git a/lib/support/lib/migrations/quotation_helpers.rb b/lib/support/lib/migrations/quotation_helpers.rb index 356d324f..13d3f49c 100644 --- a/lib/support/lib/migrations/quotation_helpers.rb +++ b/lib/support/lib/migrations/quotation_helpers.rb @@ -3,9 +3,10 @@ module Support module Migrations module QuotationHelpers - delegate :connection, to: ApplicationRecord - delegate :quote_table_name, :quote_column_name, to: :connection + + # @todo Replace this with engine connection once this is extracted. + def connection = ApplicationRecord.connection end end end diff --git a/lib/support/lib/models/reference.rb b/lib/support/lib/models/reference.rb new file mode 100644 index 00000000..e4db93ca --- /dev/null +++ b/lib/support/lib/models/reference.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Support + module Models + # A lazily-evaluated reference to an application-space model, + # which can be used to defer loading until runtime. + class Reference + extend ActiveModel::Callbacks + extend Dry::Initializer + + include Dry::Core::Equalizer.new(:name) + include Support::Realizable + + param :name, ::Support::Models::Types::Name + + option :_model_name, ::Support::Models::Types::ActiveModelName, as: :model_name, default: -> do + ::ActiveModel::Name.new(nil, nil, name) + end + + # @!attribute [r] klass + # @return [Class(ApplicationRecord)] + def klass + @klass or realized! + end + + private + + # @return [void] + def realization + @klass = name.constantize + end + end + end +end diff --git a/lib/support/lib/models/types.rb b/lib/support/lib/models/types.rb index 2f245e23..37c019d5 100644 --- a/lib/support/lib/models/types.rb +++ b/lib/support/lib/models/types.rb @@ -6,29 +6,56 @@ module Models module Types extend ::Support::Typespace + # An instance of `ActiveModel::Name`. + # + # @return [Dry::Types::Type] + ActiveModelName = Instance(::ActiveModel::Name) + # A Global ID instance or URI. + # + # @return [Dry::Types::Type] GlobalID = Constructor(GlobalID, GlobalID.method(:parse)).constrained(global_id: true) # An array of GlobalID URIs or instances. + # + # @return [Dry::Types::Type] GlobalIDList = Array.of(GlobalID) # An instance of a model # # @see ApplicationRecord + # + # @return [Dry::Types::Type] Model = Any.constrained(model: true) # An array of model instances. # # @see Model + # + # @return [Dry::Types::Type] ModelList = Array.of(Model) # A single model class. + # + # @return [Dry::Types::Type] ModelClass = Any.constrained(model_class: true) # An array of model classes # # @see ModelClass + # + # @return [Dry::Types::Type] ModelClassList = Array.of(ModelClass) + + # A string representing the name of a model, which can be used for lazy loading. + # + # @return [Dry::Types::Type] + Name = Coercible::String.constrained(format: /\A(?:::)?[A-Z]\S+\z/) + + # An array of model names. + # + # @return [Dry::Types::Type] + Names = Array.of(Name) end end end diff --git a/lib/support/lib/null_record.rb b/lib/support/lib/null_record.rb new file mode 100644 index 00000000..4abea95d --- /dev/null +++ b/lib/support/lib/null_record.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Support + # @abstract + class NullRecord < ActiveRecord::Base + self.abstract_class = true + end +end diff --git a/lib/support/lib/realizable.rb b/lib/support/lib/realizable.rb new file mode 100644 index 00000000..79002dc0 --- /dev/null +++ b/lib/support/lib/realizable.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Support + # A concern that assists in lazy evaluation of values, for instance + # models and other application-specific code that needs to be referenced in VOG. + module Realizable + extend ActiveSupport::Concern + + included do + extend ActiveModel::Callbacks + + define_model_callbacks :realize, :realization + end + + # @return [Boolean] + attr_reader :realized + + alias realized? realized + + def initialize(...) + super + + @realized = false + end + + # @api private + # @param [Boolean] reset whether to allow re-realization of an already-realized wrapper + # @return [void] + def realize!(reset: false) + # :nocov: + raise AlreadyRealized, "Already realized" if realized? && !reset + # :nocov: + + run_callbacks :realize do + realization! + + @realized = true + end + end + + # @abstract + # @return [void] + def realization + # :nocov: + raise NotImplementedError, "Must implement #realization in including class" + # :nocov: + end + + # @api private + # @return [void] + def realization! + run_callbacks :realization do + realization + end + end + + # A hook to check for realization. + # @api private + # @raise [Support::Realizable::Unrealized] if the wrapper has not been realized + # @return [void] + def realized! + raise Unrealized, "Must be realized" unless realized? + end + + # @abstract Generic realization errors. + class Error < StandardError; end + + # An error raised when attempting to realize something that has already been realized. + class AlreadyRealized < Error; end + + # An error raised when attempting to access a value before it is ready. + class Unrealized < Error; end + end +end diff --git a/lib/support/lib/schemas/type_container.rb b/lib/support/lib/schemas/type_container.rb index 0f9fb632..f4d696c1 100644 --- a/lib/support/lib/schemas/type_container.rb +++ b/lib/support/lib/schemas/type_container.rb @@ -35,7 +35,7 @@ def add!(name, type) end def add_enum!(enum_klass, single_key: nil, plural_key: nil) - single_type = Types::EnumClass[enum_klass].dry_type + single_type = Types::EnumType[enum_klass].dry_type plural_type = Types::Array.of(single_type).gql_type(enum_klass) single_key ||= enum_klass.graphql_name.underscore diff --git a/lib/support/lib/schemas/types.rb b/lib/support/lib/schemas/types.rb index 14fcbe07..c97637e7 100644 --- a/lib/support/lib/schemas/types.rb +++ b/lib/support/lib/schemas/types.rb @@ -5,7 +5,7 @@ module Schemas module Types extend ::Support::Typespace - EnumClass = Class.constrained(inherits: ::GraphQL::Schema::Enum) + EnumType = Class.constrained(inherits: ::GraphQL::Schema::Enum) SafeString = Coercible::String diff --git a/lib/support/lib/typed_set.rb b/lib/support/lib/typed_set.rb deleted file mode 100644 index 804eded1..00000000 --- a/lib/support/lib/typed_set.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -module Support - # @abstract - # - # A typed set of objects that handles inline additions - # for use with class-inherited attributes. - # - # Set up a subclass with `Support::TypedSet.of(SomeType)`. - # - # Include it in a class and define some helper methods - # by doing something like: - # - # @example - # class SomeClass - # extend Support::TypedSet.of(CustomTypes::MethodName)[:methods] - # end - class TypedSet - extend Dry::Core::ClassAttributes - - include Enumerable - - DRY_TYPE = Dry::Types::Nominal.new(Dry::Types::Type).constrained(type: Dry::Types::Type) - - defines :array_type, type: DRY_TYPE - defines :element_type, type: DRY_TYPE - - array_type Dry::Types["coercible.array"].of(DRY_TYPE) - element_type Dry::Types["any"] - - # @param [Array] raw_elements - def initialize(*raw_elements) - @elements = coerce_array(raw_elements.flatten).freeze - end - - # @param [Object] raw_element - # @return [Support::TypedSet] - def add(raw_element) - element = coerce raw_element - - unless element.in?(@elements) - new_elements = [*@elements, element] - - return self.class.new(new_elements) - else - return self - end - end - - def each - return enum_for(__method__) unless block_given? - - @elements.each do |element| - yield element - end - - return self - end - - # Inclusion checks will coerce incoming values first, - # so a collection of symbols will handle `"foo".in?(set)`. - # - # @param [Object] raw_element - def include?(raw_element) - element = coerce raw_element - - super(element) - rescue InvalidElement - # :nocov: - false - # :nocov: - end - - private - - # @param [Array] elements - # @raise Support::TypedSet::InvalidElements - # @return [Object] coerced by `array_type` - def coerce_array(elements) - self.class.array_type[elements].sort.uniq - rescue Dry::Types::CoercionError, Dry::Types::ConstraintError - raise InvalidElements, "Invalid element list for typed set: #{elements.inspect}" - end - - # @param [Object] element - # @raise Support::TypedSet::InvalidElement - # @return [Object] coerced by `element_type` - def coerce(element) - self.class.element_type[element] - rescue Dry::Types::CoercionError, Dry::Types::ConstraintError - raise InvalidElement, "Invalid element for typed set: #{element.inspect}" - end - - class << self - # Set up a module that connects a typed set instance - # to a class when extended. - # - # @param [Symbol] collection_name - # @param [Hash] options - # @rreturn [Support::TypedSets::Collected] - def [](collection_name, **options) - Support::TypedSets::Collected.new(self, collection_name, **options) - end - - # @param [Dry::Types::Type] type - # @return [Class] - def of(type) - Class.new(self).tap do |subklass| - subklass.element_type type - subklass.array_type Types::Coercible::Array.of(type) - subklass.const_set :Type, Types.Instance(subklass) - end - end - end - - class InvalidElement < TypeError; end - - class InvalidElements < TypeError; end - end -end diff --git a/lib/support/lib/typed_sets/collected.rb b/lib/support/lib/typed_sets/collected.rb deleted file mode 100644 index 9206b66f..00000000 --- a/lib/support/lib/typed_sets/collected.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -module Support - module TypedSets - # Metaprogramming module that hooks up a {Support::TypedSet} - # with a class. It should be extended onto the class. - # - # @api private - # @see Support::TypedSet.[] - class Collected < Module - include Dry::Initializer[undefined: false].define -> do - param :typed_set_klass, Types::Class.constrained(lteq: Support::TypedSet) - - param :collection_name, Types::MethodName - - option :singular_name, Types::MethodName, default: proc { collection_name.to_s.singularize } - - option :const_name, Types::ConstName, default: proc { "#{collection_name}_set".classify } - end - - # @api private - # @return [Dry::Types::Type] - attr_reader :type - - def initialize(...) - super - - @type = typed_set_klass::Type - - generate_klass_module! - generate_instance_module! - end - - def extended(klass) - klass.extend Dry::Core::ClassAttributes - - klass.defines(collection_name, type:) - - klass.const_set const_name, typed_set_klass - - klass.__send__ collection_name, typed_set_klass.new - - klass.extend @klass_module - - klass.include @instance_module - end - - private - - # @return [void] - def generate_klass_module! - @klass_module = Module.new.tap do |mod| - mod.class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def add_#{singular_name}!(raw_element) - new_values = #{collection_name}.add(raw_element) - - self.#{collection_name} new_values - - return self - end - - def has_#{singular_name}?(raw_element) - raw_element.in? self.class.#{collection_name} - end - RUBY - end - end - - # @return [void] - def generate_instance_module! - @instance_module = Module.new.tap do |mod| - mod.class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def has_#{singular_name}?(raw_element) - raw_element.in? self.class.#{collection_name} - end - - def #{collection_name} - self.class.#{collection_name} - end - RUBY - end - end - end - end -end diff --git a/lib/support/lib/typed_sets/types.rb b/lib/support/lib/typed_sets/types.rb deleted file mode 100644 index 60b55da4..00000000 --- a/lib/support/lib/typed_sets/types.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Support - module TypedSets - # Types used with {Support::TypedSet}. - # - # @api private - module Types - extend ::Support::Typespace - - ConstName = Coercible::Symbol.constrained(format: /\A[A-Z]\w+[a-zA-Z]\z/) - - MethodName = Coercible::Symbol.constrained(format: /\A[a-z]\w+[a-z]\z/) - - Type = Instance(::Dry::Types::Type) - end - end -end diff --git a/lib/support/lib/typing.rb b/lib/support/lib/typing.rb index 38d4fcab..98bf678a 100644 --- a/lib/support/lib/typing.rb +++ b/lib/support/lib/typing.rb @@ -27,6 +27,16 @@ module Typing def map_type!(key: Support::Types::Coercible::String, on: :Map) const_set on, Support::Types::Hash.map(key, const_get(:Type)) end + + # @param [Class] type_klass the GraphQL type class to generate a corresponding GraphQL type for + # @return [void] + def with_gql_type!(type_klass) + base_type = const_get(:Type) + + remove_const(:Type) + + const_set :Type, base_type.gql_type(type_klass) + end end class << self diff --git a/lib/support/lib/users/anonymous_interface.rb b/lib/support/lib/users/anonymous_interface.rb index e64afd83..8781d37e 100644 --- a/lib/support/lib/users/anonymous_interface.rb +++ b/lib/support/lib/users/anonymous_interface.rb @@ -12,7 +12,7 @@ module AnonymousInterface ID = "ANONYMOUS" # @return [ActiveSupport::TimeWithZone] - NOW = Time.zone.at(0) + NOW = Time.at(0).in_time_zone.freeze # @note For anonymous users, this is always an empty array. # @see User#allowed_actions diff --git a/lib/support/lib/users/types.rb b/lib/support/lib/users/types.rb new file mode 100644 index 00000000..eeb92bb4 --- /dev/null +++ b/lib/support/lib/users/types.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Support + module Users + # Types related to users, whether authenticated or anonymous. + # + # @see ::AnonymousUser + # @see ::User + module Types + extend Support::Typespace + + # A proc that returns a default {AnonymousUser} + DEFAULT = proc { AnonymousUser.new } + + # A proc that returns the current user from the request context, + # or falls back to an {AnonymousUser} if none is present. + # @see DEFAULT + DEFAULT_FROM_REQUEST = proc do + ::Support::Requests::Current.current_user || DEFAULT.() + end + + # A type matching a {User} + Authenticated = ModelInstance("User") + + Anonymous = Any.constrained(anonymous_user: true) + + # This is a type that will ensure a {User} is populated, and if not provided, + # nil, or otherwise invalid, it will fall back to an {AnonymousUser}. + Current = (Authenticated | Anonymous).fallback do + ::AnonymousUser.new + end.default(&DEFAULT_FROM_REQUEST) + + # A schema representing a Keycloak user profile. + KeycloakProfile = Hash.schema( + given_name?: Types::String, + family_name?: Types::String, + email?: Types::String, + username?: Types::String + ) + + # An enum switching on the state of a user's authentication. + State = Symbol.enum(:anonymous, :authenticated).constructor do |value| + case value + when Authenticated then :authenticated + else + :anonymous + end + end + + # A type matching an authenticated {User} (@see Authenticated). + User = Authenticated + end + end +end diff --git a/app/models/concerns/defines_monadic_operation.rb b/lib/support/model_concerns/defines_monadic_operation.rb similarity index 100% rename from app/models/concerns/defines_monadic_operation.rb rename to lib/support/model_concerns/defines_monadic_operation.rb diff --git a/lib/support/model_concerns/full_text_searchable.rb b/lib/support/model_concerns/full_text_searchable.rb new file mode 100644 index 00000000..cfef3086 --- /dev/null +++ b/lib/support/model_concerns/full_text_searchable.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# A concern for records that implement full-text search. +module FullTextSearchable + extend ActiveSupport::Concern + + include PgSearch::Model + + included do + extend Dry::Core::ClassAttributes + + defines :search_contexts, type: ::Support::FullTextSearching::Context::Map + + search_contexts Dry::Core::Constants::EMPTY_HASH + end + + module ClassMethods + # @param [Symbol] context_name the name of the search context to define + # @param [] columns the columns to include in the search context + # @return [void] + def full_text_searchable_with!(*columns, name: nil, **options) + name = name.presence || columns.to_sentence.parameterize(separator: ?_) + + context = ::Support::FullTextSearching::Context.new(name, **options, columns:) + + extend context.scope_module + + add_search_context!(context) + end + + private + + # @param [::Support::FullTextSearching::Context] context the search context to add to this model's search contexts + # @return [void] + def add_search_context!(context) + contexts = search_contexts.merge(context.name => context) + + search_contexts contexts.freeze + end + end +end diff --git a/lib/support/models/example_query.rb b/lib/support/models/example_query.rb index 1be36684..749ded8e 100644 --- a/lib/support/models/example_query.rb +++ b/lib/support/models/example_query.rb @@ -2,8 +2,6 @@ # Example GraphQL Queries served up on `/graphql/example_queries`. class ExampleQuery < Support::FrozenRecordHelpers::AbstractRecord - EXAMPLE_QUERIES_PATH = Rails.root.join("lib", "example_queries") - # Let's keep identifiers nice and predictable. IDENTIFIER_FORMAT = /\A[a-z][a-z-]+[a-z]\z/ @@ -25,11 +23,16 @@ class << self def assign_defaults!(record) example_query_file = "#{record['identifier']}.graphql" - example_query_path = EXAMPLE_QUERIES_PATH.join example_query_file + example_query_path = example_queries_path.join example_query_file record["query"] = example_query_path.read super end + + # @todo Replace with engine path resolution once extracted. + def example_queries_path + @example_queries_path ||= Rails.root.join("lib", "example_queries") + end end end diff --git a/lib/support/operations/filtering/run.rb b/lib/support/operations/filtering/run.rb new file mode 100644 index 00000000..8330351d --- /dev/null +++ b/lib/support/operations/filtering/run.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Support + module Filtering + class Run < Support::SimpleServiceOperation + service_klass Support::Filtering::Runner + end + end +end diff --git a/lib/support/operations/normalize_gql_type.rb b/lib/support/operations/normalize_gql_type.rb index 9ced2aff..e3fcf7c5 100644 --- a/lib/support/operations/normalize_gql_type.rb +++ b/lib/support/operations/normalize_gql_type.rb @@ -43,7 +43,7 @@ def parse(input) when /\Abool(?:ean)?\z/i "Boolean" when /\Aslug\z/i - "Types::SlugType" + "Support::GQL::SlugType" when /[^:]+::[^:]+/ # This should be a fully-realized path "::#{input}" diff --git a/lib/support/system.rb b/lib/support/system.rb index 2b6222b0..7d6ab0ec 100644 --- a/lib/support/system.rb +++ b/lib/support/system.rb @@ -5,7 +5,7 @@ module Support # A container for holding pre-initialization support services, generator helpers, etc. class System < Dry::System::Container - use :zeitwerk + use :zeitwerk, eager_load: true configure do |config| config.root = Pathname(__dir__) diff --git a/spec/factories/harvest_contributors.rb b/spec/factories/harvest_contributors.rb index 7f9a882b..8c71b0e2 100644 --- a/spec/factories/harvest_contributors.rb +++ b/spec/factories/harvest_contributors.rb @@ -2,6 +2,8 @@ FactoryBot.define do factory :harvest_contributor do + association :harvest_source + identifier { SecureRandom.uuid } email { Faker::Internet.email } diff --git a/spec/factories/roles.rb b/spec/factories/roles.rb index aff480b3..0dd5fc9a 100644 --- a/spec/factories/roles.rb +++ b/spec/factories/roles.rb @@ -3,6 +3,7 @@ FactoryBot.define do factory :role do transient do + admin_access { false } actions { [] } end @@ -10,6 +11,12 @@ custom_priority { Faker::Number.unique.rand(-19_999...20_000) } + global_access_control_list do + Roles::GlobalAccessControlList.define do |gacl| + gacl.allow! "admin.access" if admin_access + end + end + %i[admin manager editor reviewer depositor reader].each do |key| system_role = SystemRole.find key.to_s @@ -22,7 +29,13 @@ end end + trait(:with_admin_access) do + admin_access { true } + end + trait(:all_contextual) do + with_admin_access + access_control_list do Roles::AccessControlList.define do |acl| acl.allow! ?* diff --git a/spec/jobs/contributors/merge_job_spec.rb b/spec/jobs/contributors/merge_job_spec.rb new file mode 100644 index 00000000..434efe03 --- /dev/null +++ b/spec/jobs/contributors/merge_job_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.describe Contributors::MergeJob, type: :job do + let_it_be(:community, refind: true) { FactoryBot.create :community } + let_it_be(:collection, refind: true) { FactoryBot.create :collection, community: } + let_it_be(:item, refind: true) { FactoryBot.create :item, collection: } + + let_it_be(:source_contributor, refind: true) { FactoryBot.create :contributor, :organization } + let_it_be(:target_contributor, refind: true) { FactoryBot.create :contributor, :person } + + let_it_be(:harvest_contributor, refind: true) do + FactoryBot.create :harvest_contributor, :organization, contributor: source_contributor + end + + let_it_be(:source_collection_contribution, refind: true) do + FactoryBot.create :collection_contribution, collection:, contributor: source_contributor, updated_at: 1.week.ago + end + + let_it_be(:source_item_contribution, refind: true) do + FactoryBot.create :item_contribution, item:, contributor: source_contributor + end + + let_it_be(:existing_target_collection_contribution, refind: true) do + FactoryBot.create :collection_contribution, collection:, contributor: target_contributor, updated_at: 6.months.ago + end + + it "merges contributions from the source to the target" do + expect do + described_class.perform_now(source_contributor, target_contributor) + end.to execute_safely + .and change(Contributor, :count).by(-1) + .and change(CollectionContribution, :count).by(-1) + .and keep_the_same(ItemContribution, :count) + .and keep_the_same { target_contributor.collection_contributions.count } + .and change { target_contributor.item_contributions.count }.by(1) + .and change { existing_target_collection_contribution.reload.updated_at } + .and change { harvest_contributor.reload.contributor }.from(source_contributor).to(target_contributor) + end +end diff --git a/spec/models/contributor_spec.rb b/spec/models/contributor_spec.rb index fe132a37..361cd63f 100644 --- a/spec/models/contributor_spec.rb +++ b/spec/models/contributor_spec.rb @@ -9,24 +9,6 @@ let_it_be(:collection_contributions) { FactoryBot.create_list :collection_contribution, collection_count, contributor: } let_it_be(:item_contributions) { FactoryBot.create_list :item_contribution, item_count, contributor: } - describe "#attach!" do - it "can attach a collection" do - expect do - contributor.attach! FactoryBot.create :collection - end.to change(CollectionContribution, :count).by(1). - and change { contributor.reload.collection_contribution_count }.by(1). - and change { contributor.reload.contribution_count }.by(1) - end - - it "can attach an item" do - expect do - contributor.attach! FactoryBot.create :item - end.to change(ItemContribution, :count).by(1). - and change { contributor.reload.item_contribution_count }.by(1). - and change { contributor.reload.contribution_count }.by(1) - end - end - describe "#count_collection_contributions!" do context "when the collection contributions have been destroyed" do before do diff --git a/spec/operations/roles/calculate_system_roles_spec.rb b/spec/operations/roles/calculate_system_roles_spec.rb deleted file mode 100644 index 59c2a94c..00000000 --- a/spec/operations/roles/calculate_system_roles_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Roles::CalculateSystemRoles, type: :operation do - it "calculates the expected amount of roles" do - expect_calling.to have(6).items - end -end diff --git a/spec/operations/roles/calculate_system_spec.rb b/spec/operations/roles/calculate_system_spec.rb new file mode 100644 index 00000000..f54577df --- /dev/null +++ b/spec/operations/roles/calculate_system_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Roles::CalculateSystem, type: :operation do + it "calculates the expected amount of roles" do + expect_calling.to succeed.with(have(7).items) + end +end diff --git a/spec/operations/submission_targets/batch_publish_spec.rb b/spec/operations/submission_targets/batch_publish_spec.rb index 29fb330e..228c6960 100644 --- a/spec/operations/submission_targets/batch_publish_spec.rb +++ b/spec/operations/submission_targets/batch_publish_spec.rb @@ -56,7 +56,8 @@ expect do flush_enqueued_jobs - end.to change { approved_entity.reload.submission_status }.from("submission_draft").to("submission_published") + end.to execute_safely + .and change { approved_entity.reload.submission_status }.from("submission_draft").to("submission_published") .and change { approved_submission.current_state(force_reload: true) }.from("approved").to("published") .and keep_the_same { rejected_submission.current_state(force_reload: true) } .and change(SubmissionBatchPublicationTransition.to_finished, :count).by(1) diff --git a/spec/operations/users/fetch_author_spec.rb b/spec/operations/users/fetch_author_spec.rb new file mode 100644 index 00000000..ee12e6a0 --- /dev/null +++ b/spec/operations/users/fetch_author_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe Users::FetchAuthor, type: :operation do + let_it_be(:user, refind: true) { FactoryBot.create :user } + + let_it_be(:contributor, refind: true) do + FactoryBot.create :contributor, :person + end + + context "when the user has an associated author" do + before do + user.link_contributor!(contributor) + end + + it "returns the associated contributor" do + expect do + expect_calling_with(user).to succeed.with(contributor) + end.to keep_the_same(ContributorUserLink, :count) + .and keep_the_same(Contributor, :count) + + expect(user.primary_contributor).to eq(contributor) + end + end + + context "when the user does not have an associated author" do + it "creates and returns a default author" do + expect do + expect_calling_with(user).to succeed.with a_kind_of(::Contributor) + end.to change(Contributor, :count).by(1) + .and change(ContributorUserLink, :count).by(1) + + expect(user.primary_contributor).to be_a_kind_of(::Contributor) + end + end +end diff --git a/spec/policies/collection_contribution_policy_spec.rb b/spec/policies/collection_contribution_policy_spec.rb index 53de79b1..3d7406ae 100644 --- a/spec/policies/collection_contribution_policy_spec.rb +++ b/spec/policies/collection_contribution_policy_spec.rb @@ -3,11 +3,19 @@ RSpec.describe CollectionContributionPolicy, type: :policy do include_context "policy setup" + let_it_be(:community, refind: true) { FactoryBot.create :community } + let_it_be(:editor_role, refind: true) { FactoryBot.create :role, :editor } - let_it_be(:collection, refind: true) { FactoryBot.create :collection } + let_it_be(:reader_role, refind: true) { FactoryBot.create :role, :reader } + + let_it_be(:collection, refind: true) { FactoryBot.create :collection, community: } let_it_be(:contributor, refind: true) { FactoryBot.create :contributor, :person } + let_it_be(:hidden_collection, refind: true) { FactoryBot.create :collection, :hidden, community: } + + let_it_be(:hidden_collection_contribution, refind: true) { FactoryBot.create :collection_contribution, collection: hidden_collection, contributor: } + let_it_be(:collection_contribution, refind: true) { FactoryBot.create :collection_contribution, collection:, contributor: } let(:record) { collection_contribution } @@ -24,6 +32,56 @@ succeed "as an anonymous user" do let(:user) { anonymous_user } end + + context "when the contributable is hidden" do + let(:record) { hidden_collection_contribution } + + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as an editor with an inherited role" do + before { grant_access! editor_role, on: community, to: user } + end + + succeed "as a reader with an inherited role" do + before { grant_access! reader_role, on: community, to: user } + end + + failed "as a regular user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end + end + end + + shared_examples_for "a permission that requires reader access to the collection" do + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as an editor" do + before do + grant_access! editor_role, on: collection, to: user + end + end + + succeed "as a reader" do + before do + grant_access! reader_role, on: collection, to: user + end + end + + failed "as a regular user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end end shared_examples_for "a permission that requires update access to the collection" do @@ -47,7 +105,7 @@ end describe_rule :read? do - include_examples "a permission that requires update access to the collection" + include_examples "a permission that requires reader access to the collection" end describe_rule :show? do @@ -79,10 +137,38 @@ end end + context "as an editor with an inherited role" do + before { grant_access! editor_role, on: community, to: user } + + it "includes accessible records" do + is_expected.to include(record) + end + + it "includes hidden records" do + is_expected.to include(hidden_collection_contribution) + end + end + + context "as a reader with an inherited role" do + before { grant_access! reader_role, on: community, to: user } + + it "includes accessible records" do + is_expected.to include(record) + end + + it "includes hidden records" do + is_expected.to include(hidden_collection_contribution) + end + end + context "as a regular user" do it "includes accessible records" do is_expected.to include(record) end + + it "excludes inaccessible records" do + is_expected.not_to include(hidden_collection_contribution) + end end context "as an anonymous user" do @@ -91,6 +177,10 @@ it "includes accessible records" do is_expected.to include(record) end + + it "excludes inaccessible records" do + is_expected.not_to include(hidden_collection_contribution) + end end end end diff --git a/spec/policies/contributor_policy_spec.rb b/spec/policies/contributor_policy_spec.rb index 425aa1cf..e0822002 100644 --- a/spec/policies/contributor_policy_spec.rb +++ b/spec/policies/contributor_policy_spec.rb @@ -99,8 +99,8 @@ context "as an anonymous user" do let(:user) { anonymous_user } - it "excludes all records" do - is_expected.to exclude(record) + it "includes all records" do + is_expected.to include(record) end end end diff --git a/spec/policies/contributor_user_link_policy_spec.rb b/spec/policies/contributor_user_link_policy_spec.rb new file mode 100644 index 00000000..0f82f0ae --- /dev/null +++ b/spec/policies/contributor_user_link_policy_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +RSpec.describe ContributorUserLinkPolicy, type: :policy do + include_context "policy setup" + + let_it_be(:community, refind: true) { FactoryBot.create :community } + + let_it_be(:manager_role) { FactoryBot.create :role, :manager } + + let_it_be(:editor_role) { FactoryBot.create :role, :editor } + + let_it_be(:manager, refind: true) do + FactoryBot.create(:user).tap do |user| + grant_access! manager_role, on: community, to: user + end + end + + let_it_be(:editor, refind: true) do + FactoryBot.create(:user).tap do |user| + grant_access! editor_role, on: community, to: user + end + end + + let_it_be(:contributor, refind: true) { FactoryBot.create :contributor, :person } + + let_it_be(:linked_user, refind: true) { FactoryBot.create :user } + + let_it_be(:contributor_user_link, refind: true) { FactoryBot.create :contributor_user_link, :primary, contributor:, user: linked_user } + + let_it_be(:other_contributor_user_link, refind: true) { FactoryBot.create :contributor_user_link } + + let(:record) { contributor_user_link } + + shared_examples_for "a rule that requires access to either the contributor or the user" do |rule| + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as a manager" do + let(:user) { manager } + end + + succeed "as an editor" do + let(:user) { editor } + end + + succeed "as the linked user" do + let(:user) { linked_user } + end + + failed "as a regular user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end + end + + shared_examples_for "a prohibited rule" do + failed "as an admin" do + let(:user) { admin } + end + + failed "as a manager" do + let(:user) { manager } + end + + failed "as an editor" do + let(:user) { editor } + end + + failed "as a regular user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end + end + + shared_examples_for "a rule that requires destroy access" do + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as a manager" do + let(:user) { manager } + end + + failed "as an editor" do + let(:user) { editor } + end + + failed "as the linked user" do + let(:user) { linked_user } + end + + failed "as a regular user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end + end + + describe_rule :read? do + it_behaves_like "a rule that requires access to either the contributor or the user" + end + + describe_rule :show? do + it_behaves_like "a rule that requires access to either the contributor or the user" + end + + describe_rule :create? do + it_behaves_like "a prohibited rule" + end + + describe_rule :update? do + it_behaves_like "a prohibited rule" + end + + describe_rule :destroy? do + it_behaves_like "a rule that requires destroy access" + end + + describe "relation scope" do + let(:target) { ContributorUserLink.all } + + subject { policy.apply_scope(target, type: :active_record_relation) } + + shared_examples_for "all records" do + it "includes everything" do + is_expected.to include record, other_contributor_user_link + end + end + + shared_examples_for "no records" do + it "includes nothing" do + is_expected.to be_empty + end + end + + context "as an admin" do + let(:user) { admin } + + include_examples "all records" + end + + context "as a manager" do + let(:user) { manager } + + include_examples "all records" + end + + context "as an editor" do + let(:user) { editor } + + include_examples "all records" + end + + context "as the linked user" do + let(:user) { linked_user } + + it "includes linked records" do + is_expected.to include record + end + + it "excludes unlinked records" do + is_expected.to exclude other_contributor_user_link + end + end + + context "as a regular user" do + let(:user) { regular_user } + + include_examples "no records" + end + + context "as an anonymous user" do + let(:user) { anonymous_user } + + include_examples "no records" + end + end +end diff --git a/spec/policies/item_contribution_policy_spec.rb b/spec/policies/item_contribution_policy_spec.rb index 3b3e6d9a..3b196088 100644 --- a/spec/policies/item_contribution_policy_spec.rb +++ b/spec/policies/item_contribution_policy_spec.rb @@ -3,11 +3,21 @@ RSpec.describe ItemContributionPolicy, type: :policy do include_context "policy setup" + let_it_be(:community, refind: true) { FactoryBot.create :community } + let_it_be(:collection, refind: true) { FactoryBot.create :collection, community: } + let_it_be(:editor_role, refind: true) { FactoryBot.create :role, :editor } - let_it_be(:item, refind: true) { FactoryBot.create :item } + let_it_be(:reader_role, refind: true) { FactoryBot.create :role, :reader } + let_it_be(:contributor, refind: true) { FactoryBot.create :contributor, :person } + let_it_be(:hidden_item, refind: true) { FactoryBot.create :item, :hidden, collection: } + + let_it_be(:hidden_item_contribution, refind: true) { FactoryBot.create :item_contribution, item: hidden_item, contributor: } + + let_it_be(:item, refind: true) { FactoryBot.create :item, collection: } + let_it_be(:item_contribution, refind: true) { FactoryBot.create :item_contribution, item:, contributor: } let(:record) { item_contribution } @@ -24,9 +34,59 @@ succeed "as an anonymous user" do let(:user) { anonymous_user } end + + context "when the contributable is hidden" do + let(:record) { hidden_item_contribution } + + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as an editor with an inherited role" do + before { grant_access! editor_role, on: community, to: user } + end + + succeed "as a reader with an inherited role" do + before { grant_access! reader_role, on: community, to: user } + end + + failed "as a regular user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end + end + end + + shared_examples_for "a permission that contextual reader permissions for the item" do + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as an editor" do + before do + grant_access! editor_role, on: item, to: user + end + end + + succeed "as a reader" do + before do + grant_access! reader_role, on: item, to: user + end + end + + failed "as a regular user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end end - shared_examples_for "a permission that requires update access to the item" do + shared_examples_for "a permission that contextual update permissions for the item" do succeed "as an admin" do let(:user) { admin } end @@ -37,6 +97,12 @@ end end + failed "as a reader" do + before do + grant_access! reader_role, on: item, to: user + end + end + failed "as a regular user" do let(:user) { regular_user } end @@ -47,7 +113,7 @@ end describe_rule :read? do - include_examples "a permission that requires update access to the item" + include_examples "a permission that contextual reader permissions for the item" end describe_rule :show? do @@ -55,15 +121,15 @@ end describe_rule :create? do - include_examples "a permission that requires update access to the item" + include_examples "a permission that contextual update permissions for the item" end describe_rule :update? do - include_examples "a permission that requires update access to the item" + include_examples "a permission that contextual update permissions for the item" end describe_rule :destroy? do - include_examples "a permission that requires update access to the item" + include_examples "a permission that contextual update permissions for the item" end describe "relation scope" do @@ -79,10 +145,38 @@ end end + context "as an editor with an inherited role" do + before { grant_access! editor_role, on: community, to: user } + + it "includes accessible records" do + is_expected.to include(record) + end + + it "includes hidden records" do + is_expected.to include(hidden_item_contribution) + end + end + + context "as a reader with an inherited role" do + before { grant_access! reader_role, on: community, to: user } + + it "includes accessible records" do + is_expected.to include(record) + end + + it "includes hidden records" do + is_expected.to include(hidden_item_contribution) + end + end + context "as a regular user" do it "includes accessible records" do is_expected.to include(record) end + + it "excludes inaccessible records" do + is_expected.not_to include(hidden_item_contribution) + end end context "as an anonymous user" do @@ -91,6 +185,10 @@ it "includes accessible records" do is_expected.to include(record) end + + it "excludes inaccessible records" do + is_expected.not_to include(hidden_item_contribution) + end end end end diff --git a/spec/policies/item_policy_spec.rb b/spec/policies/item_policy_spec.rb index f6f204e5..0d8d179b 100644 --- a/spec/policies/item_policy_spec.rb +++ b/spec/policies/item_policy_spec.rb @@ -3,56 +3,58 @@ RSpec.describe ItemPolicy, type: :policy do include_context "policy setup" - let_it_be(:item, refind: true) { FactoryBot.create :item, title: "Item" } + let_it_be(:community, refind: true) { FactoryBot.create(:community) } - let_it_be(:subitem, refind: true) { FactoryBot.create :item, parent: item, title: "Subitem" } + let_it_be(:collection, refind: true) { FactoryBot.create(:collection, community:) } - let_it_be(:other_item, refind: true) { FactoryBot.create :item, title: "Other Item" } + let_it_be(:other_community, refind: true) { FactoryBot.create(:community) } - let_it_be(:submission, refind: true) { FactoryBot.create :submission, :item } + let_it_be(:other_collection, refind: true) { FactoryBot.create(:collection, community: other_community) } - let_it_be(:submission_item, refind: true) do - submission.entity + let_it_be(:item_schema_version, refind: true) { FactoryBot.create(:schema_version, :item) } + + let_it_be(:submission_target, refind: true) do + collection.fetch_submission_target!.tap do |st| + st.configure!(schema_versions: [item_schema_version], deposit_mode: :direct) + st.transition_to! :open + end end - let_it_be(:contextual_role) { FactoryBot.create :role, :all_contextual } + let_it_be(:reviewer, refind: true) do + FactoryBot.create(:user).tap do |user| + FactoryBot.create(:submission_target_reviewer, submission_target:, user:) + end.reload + end - let(:record) { item } + let_it_be(:submitter, refind: true) do + FactoryBot.create(:user, depositor_on: collection) + end - describe_rule :show? do - succeed "as an admin" do - let(:user) { admin } - end + let_it_be(:submission, refind: true) do + FactoryBot.create(:submission, + submission_target:, + schema_version: item_schema_version, + parent_entity: collection, + user: submitter, + title: "Test Submission" + ) + end - context "as a user with all contextual permissions" do - before { grant_access! contextual_role, on: item, to: user } + let_it_be(:submission_item, refind: true) { submission.entity } - succeed "on an item" - succeed "on a subitem" do - let(:record) { subitem } - end - end + let_it_be(:item, refind: true) { FactoryBot.create :item, collection:, title: "Item" } - succeed "as a random user with no permissions" + let_it_be(:subitem, refind: true) { FactoryBot.create :item, parent: item, collection:, title: "Subitem" } - succeed "as an anonymous user" do - let(:user) { anonymous_user } - end + let_it_be(:hidden_item, refind: true) { FactoryBot.create :item, :hidden, collection:, title: "Hidden Item" } - context "when the item is hidden" do - let(:record) { FactoryBot.create :item, :hidden } + let_it_be(:other_item, refind: true) { FactoryBot.create :item, collection: other_collection, title: "Other Item" } - failed "as a random user" do - let(:user) { regular_user } - end + let_it_be(:contextual_role) { FactoryBot.create :role, :all_contextual } - failed "as an anonymous user" do - let(:user) { anonymous_user } - end - end - end + let(:record) { item } - describe_rule :create? do + shared_examples_for "a rule that requires privileges" do succeed "as an admin" do let(:user) { admin } end @@ -61,9 +63,14 @@ before { grant_access! contextual_role, on: item, to: user } succeed "on an item" + succeed "on a subitem" do let(:record) { subitem } end + + failed "on an unrelated item" do + let(:record) { other_item } + end end failed "as a random user with no permissions" @@ -73,7 +80,7 @@ end end - describe_rule :update? do + shared_examples_for "a rule that requires admin privileges" do succeed "as an admin" do let(:user) { admin } end @@ -81,10 +88,15 @@ context "as a user with all contextual permissions" do before { grant_access! contextual_role, on: item, to: user } - succeed "on an item" - succeed "on a subitem" do + failed "on an item" + + failed "on a subitem" do let(:record) { subitem } end + + failed "on an unrelated item" do + let(:record) { other_item } + end end failed "as a random user with no permissions" @@ -94,7 +106,7 @@ end end - describe_rule :destroy? do + shared_examples_for "a rule that requires no special privileges" do succeed "as an admin" do let(:user) { admin } end @@ -103,57 +115,198 @@ before { grant_access! contextual_role, on: item, to: user } succeed "on an item" + succeed "on a subitem" do let(:record) { subitem } end + + succeed "on an unrelated item" do + let(:record) { other_item } + end end - failed "as a random user with no permissions" + succeed "as a random user with no permissions" - failed "as an anonymous user" do + succeed "as an anonymous user" do let(:user) { anonymous_user } end end - describe_rule :create_items? do - succeed "as an admin" do - let(:user) { admin } - end + shared_examples_for "a rule that requires privileges to interact with a hidden entity" do + context "when the item is hidden" do + let(:record) { hidden_item } - context "as a user with all contextual permissions" do - before { grant_access! contextual_role, on: item, to: user } + succeed "as an admin" do + let(:user) { admin } + end - succeed "on an item" - succeed "on a subitem" do - let(:record) { subitem } + succeed "as a user with all contextual permissions" do + before { grant_access! contextual_role, on: hidden_item, to: user } + end + + failed "as a random user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } end end + end - failed "as a random user with no permissions" + shared_examples_for "a rule that requires depositor or reviewer privileges for submission drafts" do + context "when the item is a submission draft" do + let(:record) { submission_item } - failed "as an anonymous user" do - let(:user) { anonymous_user } + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as the submitter" do + let(:user) { submitter } + end + + succeed "as a reviewer" do + let(:user) { reviewer } + end + + failed "as a random user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end end end - describe_rule :manage_access? do - succeed "as an admin" do - let(:user) { admin } + shared_examples_for "a rule that requires depositor privileges for submission drafts" do + context "when the item is a submission draft" do + let(:record) { submission_item } + + succeed "as an admin" do + let(:user) { admin } + end + + succeed "as the submitter" do + let(:user) { submitter } + end + + failed "as a reviewer" do + let(:user) { reviewer } + end + + failed "as a random user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end end + end - context "as a user with all contextual permissions" do - before { grant_access! contextual_role, on: item, to: user } + shared_examples_for "a rule that requires reviewer privileges for submission drafts" do + context "when the item is a submission draft" do + let(:record) { submission_item } - failed "on the item" + succeed "as an admin" do + let(:user) { admin } + end + + failed "as the submitter" do + let(:user) { submitter } + end + + succeed "as a reviewer" do + let(:user) { reviewer } + end + + failed "as a random user" do + let(:user) { regular_user } + end + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end end + end - failed "as a random user with no permissions" + shared_examples_for "a rule that is prohibited for submission drafts" do + context "when the item is a submission draft" do + let(:record) { submission_item } - failed "as an anonymous user" do - let(:user) { anonymous_user } + failed "as an admin" do + let(:user) { admin } + end + + failed "as the submitter" do + let(:user) { submitter } + end + + failed "as a random user" + + failed "as an anonymous user" do + let(:user) { anonymous_user } + end end end + describe_rule :read? do + it_behaves_like "a rule that requires privileges" + it_behaves_like "a rule that requires privileges to interact with a hidden entity" + it_behaves_like "a rule that requires depositor or reviewer privileges for submission drafts" + end + + describe_rule :show? do + it_behaves_like "a rule that requires no special privileges" + it_behaves_like "a rule that requires privileges to interact with a hidden entity" + it_behaves_like "a rule that requires depositor or reviewer privileges for submission drafts" + end + + describe_rule :create? do + it_behaves_like "a rule that requires privileges" + end + + describe_rule :update? do + it_behaves_like "a rule that requires privileges" + it_behaves_like "a rule that requires depositor privileges for submission drafts" + end + + describe_rule :destroy? do + it_behaves_like "a rule that requires privileges" + it_behaves_like "a rule that is prohibited for submission drafts" + end + + describe_rule :create_items? do + it_behaves_like "a rule that requires privileges" + it_behaves_like "a rule that is prohibited for submission drafts" + end + + describe_rule :manage_access? do + it_behaves_like "a rule that requires admin privileges" + end + + describe_rule :alter_schema_version? do + it_behaves_like "a rule that requires privileges" + + it_behaves_like "a rule that is prohibited for submission drafts" + end + + describe_rule :deposit? do + it_behaves_like "a rule that requires depositor privileges for submission drafts" + end + + describe_rule :reparent? do + it_behaves_like "a rule that requires privileges" + + it_behaves_like "a rule that is prohibited for submission drafts" + end + + describe_rule :review? do + it_behaves_like "a rule that requires reviewer privileges for submission drafts" + end + describe "relation scope" do let(:target) { Item.all } @@ -171,18 +324,45 @@ before do grant_access! contextual_role, on: item, to: user + subitem.update!(visibility: :hidden) + other_item.update!(visibility: :hidden) end - it "excludes hidden records" do - is_expected.to exclude(other_item).and include(item, subitem) + it "includes the record the user has been assigned" do + is_expected.to include(item) end - it "excludes unpublished records" do + it "includes hidden records within the user's purview" do + is_expected.to include(subitem) + end + + it "excludes hidden records outside the user's purview", :aggregate_failures do + is_expected.to exclude(other_item) + is_expected.to exclude(hidden_item) + end + + it "excludes unpublished records the user does not have access to" do is_expected.to exclude(submission_item) end end + context "as a reviewer" do + let(:user) { reviewer } + + it "includes submission drafts under the reviewer's purview" do + is_expected.to include(submission_item) + end + end + + context "as a submitter" do + let(:user) { submitter } + + it "includes the submitter's submission drafts" do + is_expected.to include(submission_item) + end + end + context "as a random user" do before { other_item.update!(visibility: :hidden) } diff --git a/spec/policies/submission_target_reviewer_policy_spec.rb b/spec/policies/submission_target_reviewer_policy_spec.rb index 9aa47a73..466832b3 100644 --- a/spec/policies/submission_target_reviewer_policy_spec.rb +++ b/spec/policies/submission_target_reviewer_policy_spec.rb @@ -16,7 +16,7 @@ let(:user) { regular_user } end - succeed "as an anonymous user" do + failed "as an anonymous user" do let(:user) { anonymous_user } end end @@ -30,7 +30,7 @@ let(:user) { regular_user } end - succeed "as an anonymous user" do + failed "as an anonymous user" do let(:user) { anonymous_user } end end @@ -99,8 +99,8 @@ context "as an anonymous user" do let(:user) { anonymous_user } - it "includes accessible records" do - is_expected.to include(record) + it "excludes accessible records" do + is_expected.to exclude(record) end end end diff --git a/spec/requests/graphql/mutations/contributor_claim_spec.rb b/spec/requests/graphql/mutations/contributor_claim_spec.rb new file mode 100644 index 00000000..ca86234a --- /dev/null +++ b/spec/requests/graphql/mutations/contributor_claim_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +RSpec.describe Mutations::ContributorClaim, type: :request, graphql: :mutation, grants_access: true do + mutation_query! <<~GRAPHQL + mutation ContributorClaim($input: ContributorClaimInput!) { + contributorClaim(input: $input) { + contributor { + ... ContributorFragment + } + + user { + ... UserFragment + } + + contributorUserLink { + id + slug + linkage + + contributor { + ... ContributorFragment + } + + user { + ... UserFragment + } + } + + ... ErrorFragment + } + } + + fragment ContributorFragment on Contributor { + ... on Node { + id + } + + ... on Sluggable { + slug + } + + ... on ContributorBase { + kind + claimed + + canClaim { + ... AuthorizationResultFragment + } + + canLinkUser { + ... AuthorizationResultFragment + } + + canMergeSource { + ... AuthorizationResultFragment + } + + canMergeTarget { + ... AuthorizationResultFragment + } + } + } + + fragment UserFragment on User { + id + slug + + canClaimContributor { + ... AuthorizationResultFragment + } + + primaryContributor { + ... ContributorFragment + } + } + GRAPHQL + + let_it_be(:community, refind: true) { FactoryBot.create(:community) } + let_it_be(:collection, refind: true) { FactoryBot.create(:collection, community:) } + + let_it_be(:depositor_role) { Role.fetch(:depositor) } + let_it_be(:reviewer_role) { Role.fetch(:reviewer) } + + let_it_be(:other_user, refind: true) { FactoryBot.create :user } + + let_it_be(:unclaimed_contributor, refind: true) { FactoryBot.create :contributor, :person } + + let_it_be(:claimed_contributor, refind: true) do + FactoryBot.create(:contributor, :person).tap do |contributor| + contributor.link_user!(other_user, linkage: :primary) + end + end + + let_it_be(:other_contributor, refind: true) { FactoryBot.create :contributor, :person } + + let(:can_claim) { false } + let(:can_link_user) { false } + let(:can_merge_source) { false } + let(:can_merge_target) { false } + + let(:can_claim_contributor) { false } + + let!(:contributor) { unclaimed_contributor } + let!(:user) { current_user } + let!(:user_id) { user.to_encoded_id } + + let_mutation_input!(:contributor_id) { contributor.to_encoded_id } + + let(:valid_mutation_shape) do + gql.mutation(:contributor_claim) do |m| + m.prop(:contributor) do |c| + c[:id] = contributor_id + c[:slug] = contributor.system_slug + + c.auth_results(can_claim:, can_link_user:, can_merge_source:, can_merge_target:) + end + + m.prop(:user) do |u| + u[:id] = user_id + u[:slug] = user.system_slug + + u.auth_results(can_claim_contributor:) + end + + m.prop(:contributor_user_link) do |cul| + cul[:id] = be_an_encoded_id.of_an_existing_model + cul[:slug] = be_an_encoded_slug + + cul[:linkage] = "PRIMARY" + + cul.prop(:contributor) do |c| + c[:id] = contributor_id + c[:slug] = contributor.system_slug + + c.auth_results(can_claim:, can_link_user:, can_merge_source:, can_merge_target:) + end + + cul.prop(:user) do |u| + u[:id] = user_id + u[:slug] = user.system_slug + + u.auth_results(can_claim_contributor:) + end + end + end + end + + let(:empty_mutation_shape) do + gql.empty_mutation :contributor_claim + end + + shared_examples_for "a successful mutation" do + let(:expected_shape) { valid_mutation_shape } + + it "claims the contributor" do + expect_request! do |req| + req.effect! change(ContributorUserLink, :count).by(1) + req.effect! change { contributor.reload.claimed? }.from(false).to(true) + + req.data! expected_shape + end + end + + context "when the contributor is already claimed" do + let!(:contributor) { claimed_contributor } + + include_examples "an unauthorized mutation" + end + + context "when the user is already linked to a contributor" do + before do + current_user.link_contributor!(other_contributor, linkage: :primary) + end + + include_examples "an unauthorized mutation" + end + + context "when claiming is disabled" do + before do + GlobalConfiguration.current.tap do |config| + config.contributors.claimable = false + config.save! + end + end + + include_examples "an unauthorized mutation" + end + end + + shared_examples_for "an unauthorized mutation" do + let(:expected_shape) { empty_mutation_shape } + + it "is not authorized" do + expect_request! do |req| + req.effect! execute_safely + + req.unauthorized! + + req.data! expected_shape + end + end + end + + shared_examples_for "an authorized mutation" do + include_examples "a successful mutation" + end + + as_an_admin_user do + let(:can_link_user) { true } + let(:can_merge_source) { true } + let(:can_merge_target) { true } + + include_examples "an authorized mutation" + end + + as_a_regular_user do + include_examples "an unauthorized mutation" + + context "as a depositor" do + before do + grant_access!(depositor_role, on: collection, to: current_user) + end + + include_examples "an authorized mutation" + end + + context "as a reviewer" do + before do + grant_access!(reviewer_role, on: collection, to: current_user) + end + + include_examples "an authorized mutation" + end + end + + as_an_anonymous_user do + include_examples "an unauthorized mutation" + end +end diff --git a/spec/requests/graphql/mutations/contributor_merge_spec.rb b/spec/requests/graphql/mutations/contributor_merge_spec.rb new file mode 100644 index 00000000..6371c965 --- /dev/null +++ b/spec/requests/graphql/mutations/contributor_merge_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +RSpec.describe Mutations::ContributorMerge, type: :request, graphql: :mutation do + mutation_query! <<~GRAPHQL + mutation ContributorMerge($input: ContributorMergeInput!) { + contributorMerge(input: $input) { + source { + ... ContributorFragment + + mergeTarget { + ... ContributorFragment + } + } + + target { + ... ContributorFragment + + mergeTarget { + ... ContributorFragment + } + } + + ... ErrorFragment + } + } + + fragment ContributorFragment on Contributor { + claimed + mergeBusy + mergeSourceStatus + mergeTargetStatus + + canDestroy { + ... AuthorizationResultFragment + } + + canUpdate { + ... AuthorizationResultFragment + } + + canClaim { + ... AuthorizationResultFragment + } + + canLinkUser { + ... AuthorizationResultFragment + } + + canMergeSource { + ... AuthorizationResultFragment + } + + canMergeTarget { + ... AuthorizationResultFragment + } + + ... on Node { + id + } + } + GRAPHQL + + let_it_be(:source_contributor, refind: true) { FactoryBot.create :contributor, :person } + let_it_be(:target_contributor, refind: true) { FactoryBot.create :contributor, :person } + + let_it_be(:merging_contributor, refind: true) do + FactoryBot.create(:contributor, :person).tap do |c| + c.merge_to(target_contributor, enqueue_merge_job: false) + end + end + + let(:source) { source_contributor } + let(:target) { target_contributor } + + let(:target_can_claim) { true } + let(:target_can_link_user) { true } + + let(:target_auth_results) do + { + can_claim: target_can_claim, + can_link_user: target_can_link_user, + can_merge_source: true, + can_merge_target: true + } + end + + let_mutation_input!(:source_id) { source.to_encoded_id } + let_mutation_input!(:target_id) { target.to_encoded_id } + + let(:valid_mutation_shape) do + gql.mutation(:contributor_merge) do |m| + m.prop :source do |c| + c[:id] = source_id + c[:merge_busy] = true + c[:merge_source_status] = "MERGING" + c[:merge_target_status] = "INACTIVE" + + c.auth_results( + can_claim: false, + can_destroy: false, + can_link_user: false, + can_merge_source: true, + can_merge_target: true, + can_update: false + ) + + c.prop :merge_target do |t| + t[:id] = target_id + t[:merge_busy] = true + t[:merge_source_status] = "UNMERGED" + t[:merge_target_status] = "ACTIVE" + + t.auth_results(**target_auth_results) + end + end + + m.prop :target do |c| + c[:id] = target_id + c[:merge_busy] = true + c[:merge_source_status] = "UNMERGED" + c[:merge_target_status] = "ACTIVE" + + c.auth_results(**target_auth_results) + end + end + end + + let(:empty_mutation_shape) do + gql.empty_mutation :contributor_merge + end + + let(:global_error_key) { :_wrong } + + let(:global_error_shape) do + gql.mutation(:contributor_merge, no_errors: false) do |m| + m[:source] = be_blank + m[:target] = be_blank + + m.global_errors do |ge| + ge.error global_error_key + end + end + end + + shared_examples_for "a global error" do + it "returns an error" do + expect_request! do |req| + req.effect! keep_the_same { source.reload.updated_at } + req.effect! keep_the_same { target.reload.updated_at } + req.effect! have_enqueued_no_jobs(Contributors::MergeJob) + + req.data! global_error_shape + end + end + end + + shared_examples_for "a successful mutation" do + let(:expected_shape) { valid_mutation_shape } + + it "merges the two contributors" do + expect_request! do |req| + req.effect! have_enqueued_job(Contributors::MergeJob).with(source, target) + + req.data! expected_shape + end + end + + context "when the source and target are already being merged together" do + before do + source.merge_to(target, enqueue_merge_job: false) + end + + let(:global_error_key) { :contributor_merge_in_progress } + + include_examples "a global error" + end + + context "when the source is the same as the target" do + let(:target) { source } + + let(:global_error_key) { :contributor_merge_same_contributor } + + include_examples "a global error" + end + + context "when the source is already being merged into another contributor" do + let(:source) { merging_contributor } + let(:target) { source_contributor } + + let(:global_error_key) { :contributor_merge_source_merging } + + include_examples "a global error" + end + + context "when the target is already being merged into another contributor" do + let(:target) { merging_contributor } + + let(:global_error_key) { :contributor_merge_target_merging } + + include_examples "a global error" + end + end + + shared_examples_for "an unauthorized mutation" do + let(:expected_shape) { empty_mutation_shape } + + it "is not authorized" do + expect_request! do |req| + req.effect! execute_safely + + req.unauthorized! + + req.data! expected_shape + end + end + end + + shared_examples_for "an authorized mutation" do + include_examples "a successful mutation" + end + + as_an_admin_user do + include_examples "an authorized mutation" + end + + as_a_regular_user do + include_examples "an unauthorized mutation" + end + + as_an_anonymous_user do + include_examples "an unauthorized mutation" + end +end diff --git a/spec/requests/graphql/mutations/contributor_user_link_upsert_spec.rb b/spec/requests/graphql/mutations/contributor_user_link_upsert_spec.rb index b8d74e1e..73d13ad2 100644 --- a/spec/requests/graphql/mutations/contributor_user_link_upsert_spec.rb +++ b/spec/requests/graphql/mutations/contributor_user_link_upsert_spec.rb @@ -41,6 +41,7 @@ ... on ContributorBase { kind + claimed } } @@ -57,6 +58,7 @@ let_it_be(:other_contributor, refind: true) do FactoryBot.create(:contributor, :person) end + let_it_be(:other_user, refind: true) { FactoryBot.create(:user) } let_it_be(:contributor, refind: true) { FactoryBot.create(:contributor, :person) } @@ -70,8 +72,30 @@ let(:valid_mutation_shape) do gql.mutation(:contributor_user_link_upsert) do |m| m.prop(:contributor) do |c| - c[:id] = be_an_encoded_id.of_an_existing_model - c[:slug] = be_an_encoded_slug + c[:id] = contributor_id + c[:slug] = contributor.system_slug + end + + m.prop(:user) do |u| + u[:id] = user_id + u[:slug] = user.system_slug + end + + m.prop(:contributor_user_link) do |cul| + cul[:id] = be_an_encoded_id.of_an_existing_model + cul[:slug] = be_an_encoded_slug + + cul[:linkage] = linkage + + cul.prop(:contributor) do |c| + c[:id] = contributor_id + c[:slug] = contributor.system_slug + end + + cul.prop(:user) do |u| + u[:id] = user_id + u[:slug] = user.system_slug + end end end end diff --git a/spec/requests/graphql/mutations/submission_create_spec.rb b/spec/requests/graphql/mutations/submission_create_spec.rb index e45b858e..10c30845 100644 --- a/spec/requests/graphql/mutations/submission_create_spec.rb +++ b/spec/requests/graphql/mutations/submission_create_spec.rb @@ -19,18 +19,79 @@ } entity { - __typename + ... EntityFragment + } + } - id + ... ErrorFragment + } + } - title + fragment EntityFragment on Entity { + __typename + id + title - canUpdate { - ... AuthorizationResultFragment - } - } + canUpdate { + ... AuthorizationResultFragment + } + + canDestroy { + ... AuthorizationResultFragment + } + + ... on Item { + ... ItemFragment + } + } + + fragment ItemFragment on Item { + id + title + + contributions { + nodes { + ... ContributionFragment } - ... ErrorFragment + } + } + + fragment PersonFragment on PersonContributor { + givenName + familyName + } + + fragment ContributorFragment on Contributor { + __typename + + ... on PersonContributor { + ... PersonFragment + + canDestroy { + ... AuthorizationResultFragment + } + + canUpdate { + ... AuthorizationResultFragment + } + } + + userLink { + linkage + + user { + id + } + } + } + + fragment ContributionFragment on ItemContribution { + contributionRole { + label + } + + contributor { + ... ContributorFragment } } GRAPHQL @@ -53,6 +114,12 @@ let_mutation_input!(:title) { "Test Submission" } let_mutation_input!(:agreement_accepted) { true } + let(:can_update_contributor) { true } + let(:can_destroy_contributor) { false } + + let(:can_update_entity) { true } + let(:can_destroy_entity) { false } + let(:valid_mutation_shape) do gql.mutation(:submission_create) do |m| m.prop(:submission) do |s| @@ -64,17 +131,40 @@ s.prop :submission_target do |st| st[:id] = submission_target_id - st.prop :can_deposit do |cd| - cd[:value] = true - end + st.auth_results(can_deposit: true) end s.prop :entity do |e| e.typename("Item") e[:id] = be_an_encoded_id.of_an_existing_model - e.prop :can_update do |cu| - cu[:value] = true + e.auth_results(can_update: can_update_entity, can_destroy: can_destroy_entity) + + e.prop :contributions do |cont| + cont.array :nodes do |n| + n.item do |node| + node.prop :contribution_role do |cr| + cr[:label] = "Author" + end + + node.prop :contributor do |c| + c.typename("PersonContributor") + + c.auth_results(can_update: can_update_contributor, can_destroy: can_destroy_contributor) + + c[:given_name] = current_user.given_name + c[:family_name] = current_user.family_name + + c.prop :user_link do |ul| + ul[:linkage] = "PRIMARY" + + ul.prop :user do |u| + u[:id] = current_user.to_encoded_id + end + end + end + end + end end end end @@ -92,6 +182,9 @@ expect_request! do |req| req.effect! change(Submission, :count).by(1) req.effect! change(Item, :count).by(1) + req.effect! change(Contributor, :count).by(1) + req.effect! change(ItemContribution, :count).by(1) + req.effect! change { current_user.reload.primary_contributor }.from(nil).to(a_kind_of(Contributor)) req.data! expected_shape end @@ -139,6 +232,8 @@ end as_an_admin_user do + let(:can_destroy_contributor) { true } + include_examples "an authorized mutation" end diff --git a/spec/requests/graphql/mutations/submission_target_configure_spec.rb b/spec/requests/graphql/mutations/submission_target_configure_spec.rb index 2bca076e..143674ee 100644 --- a/spec/requests/graphql/mutations/submission_target_configure_spec.rb +++ b/spec/requests/graphql/mutations/submission_target_configure_spec.rb @@ -8,7 +8,10 @@ id slug + agreementContent + agreementContentWithFallback agreementRequired + autoApproveDepositors depositMode depositTargets { id @@ -72,6 +75,8 @@ let_mutation_input!(:agreement_required) { false } + let_mutation_input!(:auto_approve_depositors) { false } + let_mutation_input!(:description) do { sections: [ @@ -83,6 +88,8 @@ } end + let(:expected_agreement_with_fallback_content) { be_blank } + let(:valid_mutation_shape) do gql.mutation(:submission_target_configure) do |m| m.prop(:submission_target) do |st| @@ -90,7 +97,11 @@ st[:slug] = be_an_encoded_slug st[:deposit_mode] = deposit_mode + st[:agreement_content] = agreement_content st[:agreement_required] = agreement_required + st[:agreement_content_with_fallback] = expected_agreement_with_fallback_content + + st[:auto_approve_depositors] = auto_approve_depositors st.array(:deposit_targets) do |dts| if deposit_mode == "DESCENDANT" @@ -164,11 +175,63 @@ end end + context "when agreement required" do + let(:agreement_required) { true } + + let(:global_agreement) { "Global agreement content." } + + context "with no agreement content provided" do + let(:expected_agreement_with_fallback_content) { be_blank } + + let(:agreement_content) { nil } + + let(:error_shape) do + gql.mutation(:submission_target_configure, no_errors: false) do |m| + m[:submission_target] = be_blank + + m.attribute_errors do |ae| + ae.error :agreement_content, :filled? + end + end + end + + it "returns an error" do + expect_request! do |req| + req.effect! keep_the_same(SubmissionTarget, :count) + + req.data! error_shape + end + end + + context "when global config has agreement" do + let(:expected_agreement_with_fallback_content) { global_agreement } + + before do + config = GlobalConfiguration.current + + config.depositing.agreement = global_agreement + + config.save! + end + + it "falls back to global agreement content" do + expect_request! do |req| + req.effect! change(SubmissionTarget, :count).by(1) + + req.data! valid_mutation_shape + end + end + end + end + end + context "when deposit mode is DESCENDANT & requires agreement" do let(:deposit_mode) { "DESCENDANT" } let(:agreement_required) { true } let(:agreement_content) { "Agreement content goes here." } + let(:expected_agreement_with_fallback_content) { agreement_content } + context "with valid targets provided" do let(:deposit_targets) { [item, other_item] } diff --git a/spec/requests/graphql/mutations/update_global_configuration_spec.rb b/spec/requests/graphql/mutations/update_global_configuration_spec.rb index cc661988..d7f9480c 100644 --- a/spec/requests/graphql/mutations/update_global_configuration_spec.rb +++ b/spec/requests/graphql/mutations/update_global_configuration_spec.rb @@ -19,6 +19,11 @@ } } + contributors { + claimable + ownerUpdatable + } + depositing { agreement enabled @@ -209,6 +214,25 @@ end end + context "when providing contributor information" do + let_mutation_input!(:contributors) do + { + claimable: false, + owner_updatable: true, + } + end + + it "updates the contributor settings" do + expect_request! do |req| + req.effect! change { GlobalConfiguration.fetch.contributors.as_json } + req.effect! change { GlobalConfiguration.fetch.contributors.claimable? }.to(false) + req.effect! keep_the_same { GlobalConfiguration.fetch.contributors.owner_updatable? } + + req.data! expected_shape + end + end + end + context "when providing a logo" do let(:logo) { graphql_upload_from "spec", "data", "lorempixel.jpg" } diff --git a/spec/requests/graphql/query/item_spec.rb b/spec/requests/graphql/query/item_spec.rb index a480f107..5340cefa 100644 --- a/spec/requests/graphql/query/item_spec.rb +++ b/spec/requests/graphql/query/item_spec.rb @@ -193,7 +193,7 @@ end as_an_anonymous_user do - let(:expected_contributors_count) { 0 } + let(:expected_contributors_count) { item_contributions.size } include_examples "a found item" end diff --git a/spec/requests/graphql/query/roles_spec.rb b/spec/requests/graphql/query/roles_spec.rb index 12173d8e..5bb0d698 100644 --- a/spec/requests/graphql/query/roles_spec.rb +++ b/spec/requests/graphql/query/roles_spec.rb @@ -13,6 +13,7 @@ node { id name + identifier } } } @@ -26,6 +27,7 @@ let_it_be(:role_editor, refind: true) { Role.fetch(:editor) } let_it_be(:role_reviewer, refind: true) { Role.fetch(:reviewer) } let_it_be(:role_depositor, refind: true) { Role.fetch(:depositor) } + let_it_be(:role_author, refind: true) { Role.fetch(:author) } let_it_be(:role_reader, refind: true) { Role.fetch(:reader) } let_it_be(:role_a3, refind: true) do @@ -53,6 +55,7 @@ editor: role_editor, reviewer: role_reviewer, depositor: role_depositor, + author: role_author, reader: role_reader, a3: role_a3, m1: role_m1, diff --git a/spec/requests/graphql/query/submission_target_reviewer_spec.rb b/spec/requests/graphql/query/submission_target_reviewer_spec.rb new file mode 100644 index 00000000..79f39d8c --- /dev/null +++ b/spec/requests/graphql/query/submission_target_reviewer_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +RSpec.describe "Query.submissionTargetReviewer", type: :request do + let(:query) do + <<~GRAPHQL + query getSubmissionTargetReviewer($slug: Slug!) { + submissionTargetReviewer(slug: $slug) { + id + slug + + user { + id + name + } + + canUpdate { + ... AuthorizationResultFragment + } + + canDestroy { + ... AuthorizationResultFragment + } + } + } + + fragment AuthorizationResultFragment on AuthorizationResult { + value + message + reasons { + details + fullMessages + } + } + GRAPHQL + end + + let_it_be(:community, refind: true) { FactoryBot.create(:community) } + + let_it_be(:collection, refind: true) { FactoryBot.create(:collection, community:) } + + let_it_be(:item_schema_version, refind: true) { FactoryBot.create(:schema_version, :item) } + + let_it_be(:submission_target, refind: true) do + collection.fetch_submission_target!.tap do |st| + st.configure!(schema_versions: [item_schema_version], deposit_mode: :direct) + st.transition_to! :open + end + end + + let(:can_update) { false } + let(:can_destroy) { false } + + let(:found_shape) do + gql.query do |q| + q.prop :submission_target_reviewer do |m| + m[:id] = existing_model.to_encoded_id + m[:slug] = existing_model.system_slug + + m.prop :user do |u| + u[:id] = existing_model.user.to_encoded_id + u[:name] = existing_model.user.name + end + + m.auth_results(can_update:, can_destroy:) + end + end + end + + let(:blank_shape) do + gql.query do |q| + q[:submission_target_reviewer] = be_blank + end + end + + let_it_be(:existing_model, refind: true) { FactoryBot.create(:submission_target_reviewer, submission_target:) } + + let(:slug) { existing_model.system_slug } + + let(:graphql_variables) do + { slug:, } + end + + shared_examples "a found record" do + it "finds the SubmissionTargetReviewer" do + expect_request! do |req| + req.data! found_shape + end + end + end + + shared_examples "a not found record" do + it "does not find the SubmissionTargetReviewer" do + expect_request! do |req| + req.data! blank_shape + end + end + end + + shared_examples "an existing model lookup" do + context "when looking for an existing model by slug" do + include_examples "a found record" + end + end + + shared_examples "an authorized lookup" do + include_examples "an existing model lookup" + + context "when looking for an unknown model by slug" do + let(:slug) { random_slug } + + include_examples "a not found record" + end + end + + as_an_admin_user do + let(:can_update) { false } + let(:can_destroy) { true } + + include_examples "an authorized lookup" + end + + as_a_regular_user do + let(:can_update) { false } + let(:can_destroy) { false } + + include_examples "an authorized lookup" + end + + as_an_anonymous_user do + let(:can_update) { false } + let(:can_destroy) { false } + + include_examples "a not found record" + end +end diff --git a/spec/requests/graphql/query/submission_target_reviewers_spec.rb b/spec/requests/graphql/query/submission_target_reviewers_spec.rb new file mode 100644 index 00000000..ffeb9b34 --- /dev/null +++ b/spec/requests/graphql/query/submission_target_reviewers_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +RSpec.describe "Query.submissionTargetReviewers", type: :request do + let_it_be(:community, refind: true) { FactoryBot.create(:community) } + + let_it_be(:collection, refind: true) { FactoryBot.create(:collection, community:) } + + let_it_be(:item_schema_version, refind: true) { FactoryBot.create(:schema_version, :item) } + + let_it_be(:submission_target, refind: true) do + collection.fetch_submission_target!.tap do |st| + st.configure!(schema_versions: [item_schema_version], deposit_mode: :direct) + st.transition_to! :open + end + end + + context "when ordering" do + let(:query) do + <<~GRAPHQL + query getSubmissionTargetReviewerCollection($order: SubmissionTargetReviewerOrder) { + submissionTargetReviewers(order: $order) { + edges { + node { + id + slug + + user { + id + name + } + + canUpdate { + ... AuthorizationResultFragment + } + + canDestroy { + ... AuthorizationResultFragment + } + } + } + + pageInfo { + totalCount + totalUnfilteredCount + } + } + } + + fragment AuthorizationResultFragment on AuthorizationResult { + value + message + reasons { + details + fullMessages + } + } + GRAPHQL + end + + let(:can_update) { false } + let(:can_destroy) { false } + + let(:expected_shape) do + gql.query do |q| + q.prop :submission_target_reviewers do |c| + c.array :edges do |edges| + sorted_records.each do |r| + edges.item do |edge| + edge.prop :node do |n| + n[:id] = r.to_encoded_id + n[:slug] = r.system_slug + + n.prop :user do |u| + u[:id] = r.user.to_encoded_id + u[:name] = r.user.name + end + + n.auth_results(can_update:, can_destroy:) + end + end + end + end + + c.prop :page_info do |pi| + pi[:total_count] = sorted_records.size + pi[:total_unfiltered_count] = sorted_records.size + end + end + end + end + + let(:graphql_variables) do + { order:, } + end + + let(:order) { "RECENT" } + + let_it_be(:users, refind: true) do + 1.upto(4).map do |n| + FactoryBot.create(:user, name: "User #{n}") + end + end + + let_it_be(:records, refind: true) do + 1.upto(4).zip(users).map do |n, user| + attrs = { + _at: n.days.ago, + submission_target:, + user: + } + + create_record(**attrs) + end + end + + let(:sorted_records) { order_records(records, order:) } + + def create_record(_at:, **attrs) + Timecop.freeze _at do + FactoryBot.create(:submission_target_reviewer, **attrs) + end + end + + def order_records(records, order: "RECENT") + case order + when "DEFAULT" + records.sort_by { _1.user.default_tuple } + when "OLDEST" + records.sort_by(&:created_at) + else + order_records(records, order: "OLDEST").reverse! + end + end + + shared_examples_for "a properly-ordered collection" do + it "retrieves everything in the right order" do + expect_request! do |req| + req.data! expected_shape + end + end + end + + shared_examples_for "ordering by each option" do + context "when ordering DEFAULT" do + let(:order) { "DEFAULT" } + + include_examples "a properly-ordered collection" + end + + context "when ordering RECENT" do + let(:order) { "RECENT" } + + include_examples "a properly-ordered collection" + end + + context "when ordering OLDEST" do + let(:order) { "OLDEST" } + + include_examples "a properly-ordered collection" + end + end + + as_an_admin_user do + let(:can_update) { false } + let(:can_destroy) { true } + + include_examples "ordering by each option" + end + + as_a_regular_user do + let(:can_update) { false } + let(:can_destroy) { false } + + include_examples "ordering by each option" + end + + as_an_anonymous_user do + let(:can_update) { false } + let(:can_destroy) { false } + + let(:sorted_records) { [] } + + include_examples "ordering by each option" + end + end +end diff --git a/spec/services/filtering/inputs/date_match_spec.rb b/spec/services/filtering/inputs/date_match_spec.rb new file mode 100644 index 00000000..df42c934 --- /dev/null +++ b/spec/services/filtering/inputs/date_match_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.describe ::Support::Filtering::Inputs::DateMatch do + let(:gt) { Date.new(2023, 1, 1) } + let(:not_eq) { Date.new(2023, 6, 15) } + let(:lt) { Date.new(2023, 12, 31) } + + let(:attribute) { Arel::Table.new(:records)[:created_at] } + + let(:matcher) { described_class.new(gt:, lt:, not_eq:) } + + it "can compile to arel expressions", :aggregate_failures do + expression = matcher.call(attribute) + + sql = expression.to_sql + + expect(matcher).not_to be_blank + expect(matcher).to have_comparator(:gt) + expect(matcher).to have_comparator(:lt) + expect(matcher).to have_comparator(:not_eq) + expect(matcher).not_to have_comparator(:eq) + + expect(sql).to include('"records"."created_at" > \'2023-01-01\'') + expect(sql).to include('"records"."created_at" < \'2023-12-31\'') + expect(sql).to include('"records"."created_at" != \'2023-06-15\'') + end + + context "when no comparators are set" do + let(:matcher) { described_class.new } + + it "returns nil" do + expect(matcher.call(attribute)).to be_nil + end + end + + describe ".input_object" do + let(:comparators) do + { + gt:, + lt:, + not_eq:, + } + end + + let(:instance) { described_class.new(**comparators) } + let(:input_object) { described_class.build_input_object(**comparators) } + + it "prepares into a struct" do + expect(input_object.prepare).to eq instance + end + end +end diff --git a/spec/services/filtering/scopes/contributor_filter_scope_spec.rb b/spec/services/filtering/scopes/contributor_filter_scope_spec.rb new file mode 100644 index 00000000..073a3a18 --- /dev/null +++ b/spec/services/filtering/scopes/contributor_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::Contributors, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/controlled_vocabulary_filter_scope_spec.rb b/spec/services/filtering/scopes/controlled_vocabulary_filter_scope_spec.rb new file mode 100644 index 00000000..b11195a8 --- /dev/null +++ b/spec/services/filtering/scopes/controlled_vocabulary_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::ControlledVocabularies, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/controlled_vocabulary_source_filter_scope_spec.rb b/spec/services/filtering/scopes/controlled_vocabulary_source_filter_scope_spec.rb new file mode 100644 index 00000000..fed800a9 --- /dev/null +++ b/spec/services/filtering/scopes/controlled_vocabulary_source_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::ControlledVocabularySources, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/depositor_request_filter_scope_spec.rb b/spec/services/filtering/scopes/depositor_request_filter_scope_spec.rb new file mode 100644 index 00000000..278235d7 --- /dev/null +++ b/spec/services/filtering/scopes/depositor_request_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::DepositorRequests, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/harvest_message_filter_scope_spec.rb b/spec/services/filtering/scopes/harvest_message_filter_scope_spec.rb new file mode 100644 index 00000000..edcf87d6 --- /dev/null +++ b/spec/services/filtering/scopes/harvest_message_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::HarvestMessages, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/harvest_set_filter_scope_spec.rb b/spec/services/filtering/scopes/harvest_set_filter_scope_spec.rb new file mode 100644 index 00000000..a78e41a1 --- /dev/null +++ b/spec/services/filtering/scopes/harvest_set_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::HarvestSets, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/item_filter_scope_spec.rb b/spec/services/filtering/scopes/item_filter_scope_spec.rb new file mode 100644 index 00000000..96640874 --- /dev/null +++ b/spec/services/filtering/scopes/item_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::Items, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/submission_comment_filter_scope_spec.rb b/spec/services/filtering/scopes/submission_comment_filter_scope_spec.rb new file mode 100644 index 00000000..9e116d2c --- /dev/null +++ b/spec/services/filtering/scopes/submission_comment_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::SubmissionComments, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/submission_filter_scope_spec.rb b/spec/services/filtering/scopes/submission_filter_scope_spec.rb new file mode 100644 index 00000000..18a99aa6 --- /dev/null +++ b/spec/services/filtering/scopes/submission_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::Submissions, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/submission_review_filter_scope_spec.rb b/spec/services/filtering/scopes/submission_review_filter_scope_spec.rb new file mode 100644 index 00000000..671de7e2 --- /dev/null +++ b/spec/services/filtering/scopes/submission_review_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::SubmissionReviews, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/submission_target_filter_scope_spec.rb b/spec/services/filtering/scopes/submission_target_filter_scope_spec.rb new file mode 100644 index 00000000..b32d6468 --- /dev/null +++ b/spec/services/filtering/scopes/submission_target_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::SubmissionTargets, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/submission_target_reviewer_filter_scope_spec.rb b/spec/services/filtering/scopes/submission_target_reviewer_filter_scope_spec.rb new file mode 100644 index 00000000..9e75fe5c --- /dev/null +++ b/spec/services/filtering/scopes/submission_target_reviewer_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::SubmissionTargetReviewers, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/filtering/scopes/user_filter_scope_spec.rb b/spec/services/filtering/scopes/user_filter_scope_spec.rb new file mode 100644 index 00000000..7995b127 --- /dev/null +++ b/spec/services/filtering/scopes/user_filter_scope_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe ::Filtering::Scopes::Users, type: :filter_scope do + # Just smoke tests for now. +end diff --git a/spec/services/resolvers/item_resolver_spec.rb b/spec/services/resolvers/item_resolver_spec.rb new file mode 100644 index 00000000..ad404aa1 --- /dev/null +++ b/spec/services/resolvers/item_resolver_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +RSpec.describe Resolvers::ItemResolver, type: :resolver do + let_it_be(:item_schema_version, refind: true) { FactoryBot.create(:schema_version, :item) } + let_it_be(:community) { FactoryBot.create :community } + let_it_be(:collection) { FactoryBot.create :collection, community: } + + let_it_be(:other_collection) { FactoryBot.create :collection, community: } + + let_it_be(:community_manager) { FactoryBot.create :user, manager_on: community } + + let_it_be(:editor) { FactoryBot.create :user, editor_on: collection } + + let_it_be(:reviewer) { FactoryBot.create :user, reviewer_on: collection } + + let_it_be(:depositor) { FactoryBot.create :user, depositor_on: collection } + + let_it_be(:item) { FactoryBot.create :item, collection:, title: "Public Item" } + let_it_be(:hidden_item) { FactoryBot.create :item, :hidden, collection:, title: "Hidden Item" } + let_it_be(:other_item) { FactoryBot.create :item, collection: other_collection, title: "Other Public Item" } + + let_it_be(:submission_target, refind: true) do + collection.fetch_submission_target!.tap do |st| + st.configure!(schema_versions: [item_schema_version], deposit_mode: :direct) + st.transition_to! :open + end + end + + let_it_be(:submission, refind: true) do + FactoryBot.create(:submission, + submission_target:, + schema_version: item_schema_version, + parent_entity: collection, + user: depositor, + title: "Test Submission" + ) + end + + let(:global_item_count) { 4 } + let(:global_unfiltered_item_count) { 4 } + + let(:scoped_item_count) { 3 } + + let(:public_item_count) { 2 } + + let(:base_visible_count) { global_item_count } + + let(:expected_readable_count) { expected_readable_included_records.size } + let(:expected_readable_unfiltered_count) { base_visible_count } + let(:expected_updatable_count) { expected_count } + let(:expected_updatable_unfiltered_count) { base_visible_count } + + let(:expected_readable_included_records) { [] } + let(:expected_updatable_included_records) { [] } + let(:expected_readable_excluded_records) { [] } + let(:expected_updatable_excluded_records) { [] } + + let_it_be(:submission_draft_item, refind: true) { submission.entity } + + let_filter_arg!(:include_drafts) { false } + + include_examples "common resolver tests" + + shared_examples_for "optionally including drafts" do + context "when filter includeDrafts: true" do + let_filter_arg!(:include_drafts) { true } + + let(:included_records) { [submission_draft_item] } + let(:excluded_records) { [] } + + let(:expected_count) { global_item_count } + + include_examples "a full resolution" + end + end + + shared_examples_for "scoped readability: granted" do + context "when specifying access: READ_ONLY" do + let_graphql_argument!(:access) { "READ_ONLY" } + + let(:included_records) { expected_readable_included_records } + let(:excluded_records) { expected_readable_excluded_records } + let(:expected_count) { expected_readable_count } + let(:expected_unfiltered_count) { base_visible_count } + + include_examples "a full resolution" + end + end + + shared_examples_for "scoped readability: denied" do + context "when specifying access: READ_ONLY" do + let_graphql_argument!(:access) { "READ_ONLY" } + + let(:included_records) { [] } + let(:excluded_records) { [item, hidden_item, other_item, submission_draft_item] } + let(:expected_count) { 0 } + let(:expected_unfiltered_count) { base_visible_count } + + include_examples "a full resolution" + end + end + + shared_examples_for "scoped updatability: granted" do + context "when specifying access: UPDATE" do + let_graphql_argument!(:access) { "UPDATE" } + + let(:included_records) { expected_updatable_included_records } + let(:excluded_records) { expected_updatable_excluded_records } + let(:expected_count) { expected_updatable_count } + let(:expected_unfiltered_count) { expected_updatable_unfiltered_count } + + include_examples "a full resolution" + end + end + + shared_examples_for "scoped updatability: denied" do + context "when specifying access: UPDATE" do + let_graphql_argument!(:access) { "UPDATE" } + + let(:included_records) { [] } + let(:excluded_records) { [item, hidden_item, other_item, submission_draft_item] } + let(:expected_count) { 0 } + let(:expected_unfiltered_count) { base_visible_count } + + include_examples "a full resolution" + end + end + + shared_examples_for "full visibility" do + let(:included_records) { [item, hidden_item, other_item] } + let(:excluded_records) { [submission_draft_item] } + let(:expected_count) { included_records.size } + let(:expected_unfiltered_count) { global_item_count } + + let(:expected_readable_included_records) { [item, hidden_item, other_item] } + let(:expected_readable_excluded_records) { [submission_draft_item] } + let(:expected_readable_count) { expected_readable_included_records.size } + let(:expected_updatable_included_records) { [item, hidden_item, other_item] } + let(:expected_updatable_excluded_records) { [submission_draft_item] } + let(:expected_updatable_count) { expected_updatable_included_records.size } + + include_examples "a full resolution" + include_examples "optionally including drafts" + end + + shared_examples_for "scoped visibility" do + let(:base_visible_count) { global_item_count } + let(:included_records) { [item, other_item, hidden_item] } + let(:excluded_records) { [submission_draft_item] } + let(:expected_count) { included_records.size } + let(:expected_unfiltered_count) { global_item_count } + + let(:expected_readable_included_records) { [item, hidden_item] } + let(:expected_readable_excluded_records) { [other_item, submission_draft_item] } + let(:expected_readable_count) { expected_readable_included_records.size } + + let(:expected_updatable_included_records) { [item, hidden_item] } + let(:expected_updatable_excluded_records) { [other_item, submission_draft_item] } + let(:expected_updatable_count) { expected_updatable_included_records.size } + + include_examples "a full resolution" + include_examples "optionally including drafts" + end + + shared_examples_for "public item visibility only" do + let(:base_visible_count) { public_item_count } + let(:included_records) { [item, other_item] } + let(:expected_count) { public_item_count } + let(:expected_unfiltered_count) { public_item_count } + + include_examples "a full resolution" + end + + shared_examples_for "an empty result" do + let(:excluded_records) { [item, hidden_item, other_item, submission_draft_item] } + let(:expected_count) { 0 } + let(:expected_unfiltered_count) { 0 } + + include_examples "a full resolution" + end + + context "when no object is provided" do + let(:object) { nil } + + as_an_admin_user do + it_behaves_like "an empty result" + end + + as_a_regular_user do + it_behaves_like "an empty result" + end + + as_an_anonymous_user do + it_behaves_like "an empty result" + end + end + + context "against a community's items" do + let(:object) { community } + + as_an_admin_user do + it_behaves_like "full visibility" do + include_examples "scoped readability: granted" + include_examples "scoped updatability: granted" + end + end + + as_a_regular_user do + context "as a community manager" do + let(:current_user) { community_manager } + + it_behaves_like "full visibility" do + include_examples "scoped readability: granted" + + include_examples "scoped updatability: granted" + end + end + + context "as an editor" do + let(:current_user) { editor } + + it_behaves_like "scoped visibility" do + include_examples "scoped readability: granted" + + include_examples "scoped updatability: granted" + end + end + + context "as a reviewer" do + let(:current_user) { reviewer } + + it_behaves_like "scoped visibility" do + include_examples "scoped readability: granted" + + include_examples "scoped updatability: denied" + end + end + + context "as a depositor" do + let(:current_user) { depositor } + + it_behaves_like "scoped visibility" do + include_examples "scoped readability: granted" + + context "when specifying includeDrafts: true" do + let_filter_arg!(:include_drafts) { true } + + let(:expected_updatable_included_records) { [submission_draft_item] } + let(:expected_updatable_excluded_records) { [item, hidden_item, other_item] } + let(:expected_updatable_count) { 1 } + + include_examples "scoped updatability: granted" + end + end + end + + context "with no special permissions" do + it_behaves_like "public item visibility only" do + include_examples "scoped readability: denied" + include_examples "scoped updatability: denied" + end + end + end + + as_an_anonymous_user do + it_behaves_like "public item visibility only" do + include_examples "scoped readability: denied" + include_examples "scoped updatability: denied" + end + end + end +end diff --git a/spec/support/helpers/current_user_helpers.rb b/spec/support/helpers/current_user_helpers.rb new file mode 100644 index 00000000..c04c186e --- /dev/null +++ b/spec/support/helpers/current_user_helpers.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module TestHelpers + module CurrentUser + class << self + def attach_to!(config, **options) + config.include ExampleHelpers, **options + config.extend SpecHelpers, **options + config.include_context "with current user context", **options + end + end + + module ExampleHelpers + end + + module SpecHelpers + def as_an_admin_user(&) + context "as an admin" do + let(:current_user) { admin_user } + + instance_eval(&) + end + end + + def as_a_regular_user(&) + context "as a regular user" do + let(:current_user) { regular_user } + + instance_eval(&) + end + end + + def as_an_anonymous_user(&) + context "as a anonymous user" do + let(:current_user) { anonymous_user } + + instance_eval(&) + end + end + end + end +end + +RSpec.shared_context "with current user context" do + let_it_be(:anonymous_user) { AnonymousUser.new } + + let_it_be(:admin_user, refind: true) do + FactoryBot.create :user, :admin, given_name: "Admin", family_name: "User" + end + + let_it_be(:regular_user, refind: true) do + FactoryBot.create :user, given_name: "Regular", family_name: "User" + end + + let(:current_user) { anonymous_user } + + before do + [admin_user, regular_user, current_user].uniq.each do |user| + Testing::Keycloak::GlobalRegistry.users.add_existing! user + end + end +end + +RSpec.configure do |config| + TestHelpers::CurrentUser.attach_to!(config, with_current_user: true) +end diff --git a/spec/support/helpers/filter_scope_helpers.rb b/spec/support/helpers/filter_scope_helpers.rb new file mode 100644 index 00000000..6972781e --- /dev/null +++ b/spec/support/helpers/filter_scope_helpers.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative "hash_setter" + +module TestHelpers + FilterHelpers = TestHelpers::HashSetter.new(:filter_args) +end + +RSpec.shared_examples "filter scope default tests" do + context "with the scope itself" do + describe "the model relation" do + subject { model_klass } + + before do + # :nocov: + skip "no required scopes" if described_class.required_scopes.empty? + # :nocov: + end + + described_class.required_scopes.each do |scope_name| + it { is_expected.to respond_to(scope_name) } + end + end + + describe "the associated input object" do + subject { described_class.input_object } + + it { is_expected.to be_a_vog_filtering_input_object } + + described_class.arguments.each do |key, dry_type| + context "for the argument #{key}" do + let(:typing) { dry_type.gql_typing } + let(:input_key) { typing.input_key_for(key).to_s.camelize(:lower) } + + it do + is_expected.to have_argument_named(input_key) + end + end + end + end + end + + shared_examples_for "filters for a taggable record" do + let_it_be(:internal_tags) { %w[internal-tag-1] } + let_it_be(:external_tags) { %w[external-tag-1] } + let_it_be(:internally_tagged_record, refind: true) { FactoryBot.create(model_klass.default_factory, internal_tags:) } + let_it_be(:externally_tagged_record, refind: true) { FactoryBot.create(model_klass.default_factory, external_tags:) } + let_it_be(:untagged_record, refind: true) { FactoryBot.create(model_klass.default_factory) } + + let_it_be(:admin_user, refind: true) { FactoryBot.create(:user, :admin) } + + def tag_search_for(*tags, any: false) + tags.flatten! + + ::Taggings::TagSearch.new(tags:, any:) + end + + context "when filtering for external tags" do + let_filter_arg!(:external_tags_filter, key: :external_tags) { tag_search_for(external_tags) } + + it "returns records matching the external tags" do + expect_running.to include(externally_tagged_record).and exclude(internally_tagged_record, untagged_record) + end + end + + context "when filtering for internal tags" do + let_filter_arg!(:internal_tags_filter, key: :internal_tags) { tag_search_for(internal_tags) } + + context "when the user has no permissions" do + let(:current_user) { anonymous_user } + + it "nullifies the scope and returns nothing" do + expect_running.to exclude(internally_tagged_record, externally_tagged_record, untagged_record) + end + end + + context "when the user has admin permissions" do + let(:current_user) { admin_user } + + it "returns records matching the internal tags" do + expect_running.to include(internally_tagged_record).and exclude(externally_tagged_record, untagged_record) + end + end + end + end +end + +RSpec.shared_context "filter scope tests" do + let_it_be(:anonymous_user) { AnonymousUser.new } + + let_it_be(:model_klass) { described_class.model_klass } + + let(:options) { filter_args } + + let_it_be(:filter_klass) { described_class } + + let(:base_scope) { model_klass.all } + + let(:current_user) { anonymous_user } + + let(:runner_options) do + { + base_scope:, + current_user:, + filter_klass:, + options:, + } + end + + def run_filters + Support::System["filtering.run"].(model_klass, **runner_options).value! + end + + def expect_running + expect(run_filters) + end + + include_examples "filter scope default tests" +end + +RSpec.configure do |config| + config.include TestHelpers::FilterHelpers, type: :filter_scope + config.include_context "filter scope tests", type: :filter_scope +end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index b94fad7c..a2a3915b 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require "test_prof/ext/active_record_refind" - -using TestProf::Ext::ActiveRecordRefind +require_relative "current_user_helpers" module TestHelpers module GQL @@ -184,47 +182,11 @@ def wrap_graphql_query(raw_query, auth_result: "AuthorizationResultFragment".in? a << GQL::ERROR_FRAGMENT if error end.join("\n\n") end - - def as_an_admin_user(&) - context "as an admin" do - let(:current_user) { admin_user } - - instance_eval(&) - end - end - - def as_a_regular_user(&) - context "as a regular user" do - let(:current_user) { regular_user } - - instance_eval(&) - end - end - - def as_an_anonymous_user(&) - context "as a anonymous user" do - let(:current_user) { anonymous_user } - - instance_eval(&) - end - end end end end RSpec.shared_context "with default graphql context" do - let_it_be(:anonymous_user) { AnonymousUser.new } - - let_it_be(:admin_user, refind: true) do - FactoryBot.create :user, :admin, given_name: "Admin", family_name: "User" - end - - let_it_be(:regular_user, refind: true) do - FactoryBot.create :user, given_name: "Regular", family_name: "User" - end - - let(:current_user) { anonymous_user } - let(:token) { current_user.anonymous? ? nil : token_helper.build_token(from_user: current_user) } let(:query) { "" } @@ -232,15 +194,11 @@ def as_an_anonymous_user(&) let(:graphql_variables) { {} } let(:operation_name) { nil } - - before do - [admin_user, regular_user, current_user].each do |user| - Testing::Keycloak::GlobalRegistry.users.add_existing! user - end - end end RSpec.configure do |config| + TestHelpers::CurrentUser.attach_to!(config, type: :request) + config.include TestHelpers::GraphQLRequest::ExampleHelpers, type: :request config.extend TestHelpers::GraphQLRequest::SpecHelpers, type: :request config.include_context "with default graphql context", type: :request diff --git a/spec/support/helpers/resolver_helpers.rb b/spec/support/helpers/resolver_helpers.rb new file mode 100644 index 00000000..49a67750 --- /dev/null +++ b/spec/support/helpers/resolver_helpers.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require_relative "current_user_helpers" +require_relative "filter_scope_helpers" +require_relative "hash_setter" + +module TestHelpers + ContextValueHelpers = TestHelpers::HashSetter.new(:context_values) + GraphQLArgumentHelpers = TestHelpers::HashSetter.new(:graphql_arguments) + + module Resolver + module Types + extend ::Support::Typespace + + Record = Any + + Records = Array.of(Any) + + Resolver = Instance(::Resolvers::AbstractResolver) + + OptionalCount = Integer.constrained(gteq: 0).optional + end + + class ResolutionShape + include Support::Typing + include Dry::Initializer[undefined: false].define -> do + option :included, Types::Records + option :excluded, Types::Records + option :total_count, Types::OptionalCount, optional: true + option :total_unfiltered_count, Types::OptionalCount, optional: true + end + + def total_count_matches?(value) = count_matches?(value, total_count) + + def total_unfiltered_count_matches?(value) = count_matches?(value, total_unfiltered_count) + + def validate!(resolver) + validator = ResolutionValidator.new(self, resolver) + + validator.call + end + + private + + def count_matches?(actual, expected) + expected.nil? || expected === actual + end + end + + class ResolutionValidator + include ActiveModel::Validations + include Dry::Initializer[undefined: false].define -> do + param :shape, ResolutionShape::Type + param :resolver, Types::Resolver + end + + validate :check_included_records! + validate :check_excluded_records! + validate :check_total_count! + validate :check_total_unfiltered_count! + + # @return [] + def call + valid? + + errors.full_messages + end + + private + + # @return [void] + def check_included_records! + shape.included.each do |record| + errors.add(:base, "Missing expected record #{record.inspect}") unless resolver.results.include?(record) + end + end + + # @return [void] + def check_excluded_records! + shape.excluded.each do |record| + errors.add(:base, "Unexpected record included: #{record.inspect}") if resolver.results.include?(record) + end + end + + def check_total_count! + return if shape.total_count_matches?(resolver.count) + + errors.add(:base, "Expected total count #{shape.total_count}, got #{resolver.count}") + end + + def check_total_unfiltered_count! + return if shape.total_unfiltered_count_matches?(resolver.unfiltered_count) + + errors.add(:base, "Expected total unfiltered count #{shape.total_unfiltered_count}, got #{resolver.unfiltered_count}") + end + end + + # @api private + class ResolutionShaper + def initialize + @included = [] + @excluded = [] + @total_count = nil + @total_unfiltered_count = nil + end + + # @return [TestHelpers::Resolver::ResolutionShape] + def configure(&) + yield self + + return to_shape + end + + # @return [void] + def include!(*records) + @included.concat(records).uniq! + end + + # @return [void] + def exclude!(*records) + @excluded.concat(records).uniq! + end + + def total_count!(count) + @total_count = count + end + + def total_unfiltered_count!(count) + @total_unfiltered_count = count + end + + # @return [TestHelpers::Resolver::ResolutionShape] + def to_shape + ResolutionShape.new( + included: @included, + excluded: @excluded, + total_count: @total_count, + total_unfiltered_count: @total_unfiltered_count + ) + end + end + + module ExampleHelpers + extend RSpec::Matchers::DSL + + def build_shape(&) + ResolutionShaper.new.configure(&) + end + + matcher :match_shape do |shape| + match do |resolver| + # :nocov: + raise TypeError, "Expected a ResolutionShape, got #{shape.class}" unless shape.kind_of?(TestHelpers::Resolver::ResolutionShape) + raise TypeError, "Expected a Resolver, got #{resolver.class}" unless resolver.kind_of?(described_class) + # :nocov: + + @errors = shape.validate!(resolver) + + @errors.blank? + end + + description do + "match the expected resolution shape" + end + + failure_message do + "Expected resolver to match shape, but the following errors were found:\n#{@errors.join("\n")}" + end + + failure_message_when_negated do + "Expected resolver not to match shape, but it did." + end + end + end + + module SpecHelpers + def expect_shape!(&) + let(:expected_resolution_shape) { build_shape(&) } + end + end + end +end + +RSpec.shared_context "resolver tests" do + let(:object) { nil } + + let(:filter_klass) { described_class.filter_scope_klass } + + let(:filters) { filter_klass.new(**filter_args) } + + let(:or_filters) { [] } + + let(:compiled_or_filters) do + or_filters.map { filter_klass.new(**_1) } + end + + let(:current_arguments) do + graphql_arguments.merge(filters:, or_filters: compiled_or_filters) + end + + let(:graphql_context) do + Support::DryGQL::Types::BUILD_NULL_CONTEXT.(**context_values, current_arguments:, current_user:) + end + + let(:options) do + { + object:, + context: graphql_context, + } + end + + let(:included_records) { [] } + let(:excluded_records) { [] } + let(:expected_count) { nil } + let(:expected_unfiltered_count) { nil } + + let(:expected_resolution_shape) do + build_shape do |s| + s.include!(*included_records) + s.exclude!(*excluded_records) + s.total_count!(expected_count) + s.total_unfiltered_count!(expected_unfiltered_count) + end + end + + let!(:resolver_instance) do + described_class.new(**options).tap { _1.resolve(**current_arguments) } + end + + let(:resolver_params) do + resolver_instance.params + end + + let(:resolved_results) do + resolver_instance.results + end + + subject { resolver_instance } + + shared_examples_for "a full resolution" do + it "matches the expected resolution shape" do + is_expected.to match_shape(expected_resolution_shape) + end + end +end + +RSpec.shared_examples_for "common resolver tests" do + let(:expected_count_range) { 0... } + let(:expected_unfiltered_count_range) { 0... } + + describe "#count" do + it "returns an integer" do + expect(subject.count).to match_integer(expected_count_range) + end + end + + describe "#unfiltered_count" do + it "returns an integer" do + expect(subject.unfiltered_count).to match_integer(expected_unfiltered_count_range) + end + end +end + +RSpec.configure do |config| + TestHelpers::CurrentUser.attach_to!(config, type: :resolver) + + config.include TestHelpers::ContextValueHelpers, type: :resolver + config.include TestHelpers::FilterHelpers, type: :resolver + config.include TestHelpers::GraphQLArgumentHelpers, type: :resolver + + config.include TestHelpers::Resolver::ExampleHelpers, type: :resolver + config.include TestHelpers::Resolver::SpecHelpers, type: :resolver + + config.include_context "resolver tests", type: :resolver +end diff --git a/spec/support/matchers/match_integer.rb b/spec/support/matchers/match_integer.rb new file mode 100644 index 00000000..e9768153 --- /dev/null +++ b/spec/support/matchers/match_integer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :match_integer do |expected| + match do |actual| + case expected + when Range + expected.cover?(actual) + else + actual == expected + end + end +end diff --git a/spec/support/matchers/vog.rb b/spec/support/matchers/vog.rb new file mode 100644 index 00000000..24bc39a7 --- /dev/null +++ b/spec/support/matchers/vog.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :be_a_vog_filtering_input_object do + match do |actual| + actual.kind_of?(Class) && actual < ::Support::GQL::BaseFilterScopeInputObject + end + + description do + "be a VOG filtering input object" + end + + failure_message do |actual| + "expected that `#{actual.inspect}` would be a VOG filtering input object" + end +end diff --git a/spec/system/lib/side_effect_tester.rb b/spec/system/lib/side_effect_tester.rb deleted file mode 100644 index 859dc57c..00000000 --- a/spec/system/lib/side_effect_tester.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Testing - module GQL - # @see Testing::Requests::Build - class RequestTester < Dry::Struct - attribute :effects, Dry::Types["array"].optional - attribute :expectation, Dry::Types["any"].optional - attribute :data, Dry::Types["any"].optional - attribute :top_level_errors, Dry::Types["any"].optional - - def has_data? - data.present? - end - - def has_expectation? - !expectation.nil? - end - - def has_top_level_errors? - top_level_errors.present? - end - - # @return [Hash] - def inferred_options - { no_top_level_errors: } - end - - # @return [Boolean] - def no_top_level_errors - !has_top_level_errors? - end - end - end -end