diff --git a/.gitignore b/.gitignore index 1cea326..898ecf4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .bundle .config .yardoc +gemfiles/vendor Gemfile.lock InstalledFiles _yardoc diff --git a/.travis.yml b/.travis.yml index 1d6ad88..cf14233 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,8 +23,6 @@ rvm: - jruby-9.1.9.0 #- rubinius-3 gemfile: - - gemfiles/rails_4.0.gemfile - - gemfiles/rails_4.1.gemfile - gemfiles/rails_4.2.gemfile - gemfiles/rails_5.0.gemfile - gemfiles/rails_5.1.gemfile diff --git a/Appraisals b/Appraisals index af4bead..14d88fe 100644 --- a/Appraisals +++ b/Appraisals @@ -1,17 +1,5 @@ -appraise "rails-4.0" do - gem 'activerecord', "~> 4.0.0" - gem "mongoid", "~> 4.0" -end - -appraise "rails-4.1" do - gem 'activerecord', "~> 4.1.0" - - gem "mongoid", "~> 4.0" -end - appraise "rails-4.2" do gem 'activerecord', "~> 4.2.0" - gem "mongoid", "~> 4.0" end diff --git a/Gemfile b/Gemfile index 778e2a6..a5027f7 100644 --- a/Gemfile +++ b/Gemfile @@ -8,8 +8,8 @@ end group :test do gem "rspec", ">= 3" - gem "database_cleaner", "~> 1.5.3" - gem "combustion", "0.5.5" + gem "database_cleaner", ">= 1.6.1" + gem "combustion", ">= 0.7.0" gem "appraisal" gem 'coveralls', require: false gem "codeclimate-test-reporter", require: nil @@ -27,6 +27,6 @@ end platforms :ruby do gem "sqlite3" - gem "mysql2", "~> 0.3.11" + gem "mysql2", ">= 0.3.11" gem "pg" end diff --git a/README.md b/README.md index d8f28c4..bd0f05f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ model? Use named groups instead to add members to named groups such as ## Compatibility The following ORMs are supported: - * ActiveRecord 4.x, 5.x + * ActiveRecord 4.2+, 5.x * Mongoid 4.x, 5.x, 6.x The following Rubies are supported: @@ -36,9 +36,9 @@ Or install it yourself as: $ gem install groupify -### Setup +## Setup -#### Active Record +### Active Record Execute: @@ -63,7 +63,7 @@ class Assignment < ActiveRecord::Base end ``` -#### Mongoid +### Mongoid Execute: @@ -74,40 +74,65 @@ Set up your member models: ```ruby class User include Mongoid::Document - + groupify :group_member groupify :named_group_member end ``` -#### Advanced Configuration +## Test Suite + +Run the RSpec test suite by installing the `appraisal` gem and dependencies: + + $ gem install appraisal + $ appraisal install + +And then running tests using `appraisal`: -##### Groupify Model Names + $ appraisal rake -The default model names for groups and group memberships are configurable. Add the following -configuration in `config/initializers/groupify.rb` to change the model names for all classes: +## Advanced Configuration + +### Groupify Model Names + +The default classes for groups, group members and group memberships are configurable. +The default association name for groups and members is also configurable. +Add the following configuration in `config/initializers/groupify.rb` to change the model names for all classes: ```ruby Groupify.configure do |config| config.group_class_name = 'MyCustomGroup' + config.member_class_name = 'MyCustomMember' + + # Default to `false` so default associations are not automatically created + config.default_groups_association_name = :groups + config.default_members_association_name = :members + # ActiveRecord only config.group_membership_class_name = 'MyCustomGroupMembership' end ``` -The group name can also be set on a model-by-model basis for each group member by passing -the `group_class_name` option: +#### Backwards-compatible Configuration Defaults + +The new default configuration does *not* create default associations or make assumptions about your +group and group member class names. If you would like to retain the *legacy* defaults, you can +utilize the `configure_legacy_defaults!` convenience method. ```ruby -class Member < ActiveRecord::Base - groupify :group_member, group_class_name: 'MyOtherCustomGroup' +Groupify.configure do |config| + config.configure_legacy_defaults! + + # These are the legacy defaults configured for you: + # config.group_class_name = 'Group' + # config.member_class_name = 'User' + # + # config.groups_association_name = :groups + # config.members_association_name = :members end ``` -Note that each member model can only belong to a single type of group (or child classes -of that group). - -##### Member Associations on Group +### Groups: Configuring Group Members Your group class can be configured to create associations for each expected member type. For example, let's say that your group class will have users and assignments as members. @@ -119,11 +144,22 @@ class Group < ActiveRecord::Base end ``` -The `default_members` option sets the model type when accessing the `members` association. -In the example above, `group.members` would return the users who are members of this group. +In addition to your configuration, Groupify will create a default `members` association. +The default association name can be customized with the `Groupify.default_members_association_name` +setting. If the association name is set to `false`, no default association is created. + +The `default_members` option specified in the example above is used to infer the model class when accessing the +default members association (e.g. `members`). Based on the example, `group.members` would return the +users who are members of this group. Note: if `Groupify.default_members_association_name` is set to `false` +then the name specified for `default_members` will be used as the default members association name for this class +(e.g. `group.users` in this case). If that were the case, you would not need to specify `members: [:users]` because +it would be overwritten with a new default association. -If you are using single table inheritance, child classes inherit the member associations -of the parent. If your child class needs to add more members, use the `has_members` method. +If you are using single table inheritance (STI), child classes inherit the member associations +of the parent. If your child class needs to add more members, use the `has_members` method. You can specify +the same options that `has_many through` accepts to customize the association as you please. Note: when using inheritance, +it is recommended to specify the `source_type` option with the base class when you run into circular dependency +issues with your groups and members. Example: @@ -131,17 +167,202 @@ Example: class Organization < Group has_members :offices, :equipment end + +class InternationalOrganization < Organization + has_member :offices, class_name: 'CustomOfficeClass' + has_member :equipment, class_name: 'CustomEquipmentClass' + + # mitigate issues with inheritance and circular dependencies with groups and members + has_member :specific_equipment, class_name: 'SpecificEquipment', source_type: 'CustomEquipmentClass' +end ``` Mongoid works the same way by creating Mongoid relations. -## Usage +With polymorphic groups, the `default_members` option specifies the association +on the group to which members should be added. When specifying individual `has_member` +options, `default_members: true` indicates the association is the one to add new +members to. (If the `default_members` is not specified and the `members` association +does not exist, adding users to subclasses of a group can cause a +`ActiveRecord::AssociationTypeMismatch` exception.) -### Create groups and add members +Example: + +```ruby +class GroupBase < ActiveRecord::Base + self.table_name = "groups" + self.abstract_class = true +end + +class Organization < GroupBase + acts_as_group + has_member :users, class_name: 'CustomUserClass', default_members: true +end + +org = Organization.create! +user = CustomUserClass.create! + +# adds the user to the `ord.users` association +org.add user, as: 'admin' +``` + +### Group Members: Configuring Groups + +Your member class can be configured to create associations for each expected group type. +For example, let's say that your member class will have multiple types of organizations as groups. +The following configuration adds `organizations` and `international_organizations` associations +on the member model: + +```ruby +class Group < ActiveRecord::Base + groupify :group, members: [:users, :assignments], default_members: :users +end + +class Organization < Group + has_members :offices, :equipment +end + +class InternationalOrganization < Organization +end + +class Member < ActiveRecord::Base + groupify :group_member, groups: [:groups, :organizations, :international_organizations], default_groups: :groups +end +``` + +In addition to your configuration, Groupify will create a default `groups` association. +The default association name can be customized with the `Groupify.default_groups_association_name` +setting. + +The `default_groups` option specified in the example above sets the model type when accessing the +default groups association (e.g. `groups`). Based on the example, `member.groups` would return the +groups the member has a membership to. Note: if `Groupify.default_groups_association_name` is set to `false` +then the `default_groups` name will be used as the default members association name for this class +(e.g. `member.groups` in this case). + +Note: the `group_class_name` option can be specified as the default group class for backwards-compatibility. However, +unlike the `default_groups` option, a default association will not be created if `Groupify.default_groups_association_name` +is set to `false`. ```ruby +class Member < ActiveRecord::Base + groupify :group_member, group_class_name: 'MyOtherCustomGroup' +end +``` + +If you are using single table inheritance (STI), child classes inherit the group associations +of the parent. If your child class needs to add more members, use the `has_groups` method. You can specify +the same options that `has_many through` accepts to customize the association as you please. Note: when using inheritance, +it is recommended to specify the `source_type` option with the base class when you run into circular dependency +issues with your groups and members. + +Example: + +```ruby +class Group < ActiveRecord::Base + groupify :group, members: [:users, :assignments], default_members: :users +end + +class Organization < Group + has_members :offices, :equipment +end + +class InternationalOrganization < Organization +end + +class Member < ActiveRecord::Base + groupify :group_member + + has_group :owned_organizations, class_name: 'Organization' +end +``` + +### Implementing Group and Group Member on a Single Model (Active Record only) + +When a model is designated both as a group and a group member, some things can become ambiguous internally +to Groupify. Usually the context can be inferred. However, when it can't, Groupify assumes that your model +is a member. + +For example, if a `Group` can be a member and a group, the following will return groups: + +```ruby +class Group < ActiveRecord::Base + groupify :group + groupify :group_member +end + +member = Group.create! +group = Group.create! + +group.add member, as: :owner + +# This will return members who are in groups with the given membership type +Group.as(:owner) # [member] +``` + +### Polymorphic Groups and Members (Active Record Only) + +When you configure multiple models as group or member, you may need to retrieve all groups or members, +particularly if they are not single-table inheritance models. When your models are distributed across +multiple tables, Groupify provides the ability to access all groups or users with the `group.polymorphic_members` +and `member.polymorphic_groups` helper methods. This returns an `Enumerable` collection of groups or members. + +Note: this collection effectively retrieves the group memberships and includes the `group_membership.group` or +`group_membership.member` to minimize N+1 queries, then returns only the groups or members from the group memberships +results. + +You can filter on membership type: + +```ruby +# member example +user.polymorphic_groups.as(:manager) + +# group example +group.polymorphic_members.as(:manager) +``` + +If you want to treat the collection like a scope, you can pass in a block which modifies the +criteria for retrieving the group memberships. + +```ruby +# member example +user.polymorphic_groups{where(group_type: 'CustomGroup')} + +# group example +group.polymorphic_members{where(member_type: 'CustomMember')} +``` + +If you want to treat the collection like an association, you can add groups to the collection and +group memberships will be created. + +```ruby +# member example group = Group.new +user.polymorphic_groups << group +user.in_group?(group) # true +# equivalent to: +user.groups << group +user.in_group?(group) # true + +# group example user = User.new +group.polymorphic_members << user +user.in_group?(group) # true +# equivalent to: +group.members << user +user.in_group?(group) # true +``` + +See _Usage_ below for additional functionality, such as how to specify membership type + +## Usage + +### Create groups and add members + +```ruby +# NOTE: ActiveRecord groups and members must be persisted before creating memberships. +group = Group.create! +user = User.create! user.groups << group # or diff --git a/gemfiles/rails_4.0.gemfile b/gemfiles/rails_4.0.gemfile deleted file mode 100644 index b008ee6..0000000 --- a/gemfiles/rails_4.0.gemfile +++ /dev/null @@ -1,35 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "activerecord", "~> 4.0.0" -gem "mongoid", "~> 4.0" - -group :development do - gem "pry" - gem "github_changelog_generator" -end - -group :test do - gem "rspec", ">= 3" - gem "database_cleaner", "~> 1.5.3" - gem "combustion", "0.5.5" - gem "appraisal" - gem "coveralls", require: false - gem "codeclimate-test-reporter", require: nil -end - -platforms :jruby do - gem "activerecord-jdbcsqlite3-adapter" - gem "activerecord-jdbcmysql-adapter" - gem "jdbc-mysql" - gem "activerecord-jdbcpostgresql-adapter" -end - -platforms :ruby do - gem "sqlite3" - gem "mysql2", "~> 0.3.11" - gem "pg" -end - -gemspec path: "../" diff --git a/gemfiles/rails_4.1.gemfile b/gemfiles/rails_4.1.gemfile deleted file mode 100644 index b75db37..0000000 --- a/gemfiles/rails_4.1.gemfile +++ /dev/null @@ -1,35 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "activerecord", "~> 4.1.0" -gem "mongoid", "~> 4.0" - -group :development do - gem "pry" - gem "github_changelog_generator" -end - -group :test do - gem "rspec", ">= 3" - gem "database_cleaner", "~> 1.5.3" - gem "combustion", "0.5.5" - gem "appraisal" - gem "coveralls", require: false - gem "codeclimate-test-reporter", require: nil -end - -platforms :jruby do - gem "activerecord-jdbcsqlite3-adapter" - gem "activerecord-jdbcmysql-adapter" - gem "jdbc-mysql" - gem "activerecord-jdbcpostgresql-adapter" -end - -platforms :ruby do - gem "sqlite3" - gem "mysql2", "~> 0.3.11" - gem "pg" -end - -gemspec path: "../" diff --git a/groupify.gemspec b/groupify.gemspec index e72cc40..09aaad0 100644 --- a/groupify.gemspec +++ b/groupify.gemspec @@ -2,8 +2,8 @@ require File.expand_path('../lib/groupify/version', __FILE__) Gem::Specification.new do |gem| - gem.authors = ["dwbutler"] - gem.email = ["dwbutler@ucla.edu"] + gem.authors = ["dwbutler", "Joel Van Horn"] + gem.email = ["dwbutler@ucla.edu", "joel@joelvanhorn.com"] gem.description = %q{Adds group and membership functionality to Rails models} gem.summary = %q{Group functionality for Rails} gem.homepage = "https://github.com/dwbutler/groupify" @@ -19,5 +19,5 @@ Gem::Specification.new do |gem| gem.required_ruby_version = ">= 2.2" gem.add_development_dependency "mongoid", ">= 4" - gem.add_development_dependency "activerecord", ">= 4", "< 5.2" + gem.add_development_dependency "activerecord", ">= 4.2", "< 5.2" end diff --git a/lib/generators/groupify/active_record/initializer/templates/initializer.rb b/lib/generators/groupify/active_record/initializer/templates/initializer.rb index c722f5e..760a4da 100644 --- a/lib/generators/groupify/active_record/initializer/templates/initializer.rb +++ b/lib/generators/groupify/active_record/initializer/templates/initializer.rb @@ -1,9 +1,15 @@ Groupify.configure do |config| - # Configure the default group class name. - # Defaults to 'Group' + # Configure the default group and member class names. + # Default to `nil` # config.group_class_name = 'Group' + # config.member_class_name = 'User' # Configure the default group membership class name. # Defaults to 'GroupMembership' # config.group_membership_class_name = 'GroupMembership' + + # Configure the default group and member association names. + # Default to `false` - specify names to auto-create them + # config.groups_association_name = :groups + # config.members_association_name = :members end diff --git a/lib/generators/groupify/mongoid/initializer/templates/initializer.rb b/lib/generators/groupify/mongoid/initializer/templates/initializer.rb index 3632e71..760a4da 100644 --- a/lib/generators/groupify/mongoid/initializer/templates/initializer.rb +++ b/lib/generators/groupify/mongoid/initializer/templates/initializer.rb @@ -1,5 +1,15 @@ Groupify.configure do |config| - # Configure the default group class name. - # Defaults to 'Group' + # Configure the default group and member class names. + # Default to `nil` # config.group_class_name = 'Group' + # config.member_class_name = 'User' + + # Configure the default group membership class name. + # Defaults to 'GroupMembership' + # config.group_membership_class_name = 'GroupMembership' + + # Configure the default group and member association names. + # Default to `false` - specify names to auto-create them + # config.groups_association_name = :groups + # config.members_association_name = :members end diff --git a/lib/groupify.rb b/lib/groupify.rb index c870314..ed169d5 100644 --- a/lib/groupify.rb +++ b/lib/groupify.rb @@ -2,18 +2,75 @@ module Groupify mattr_accessor :group_membership_class_name, - :group_class_name + :group_class_name, + :member_class_name, + :members_association_name, + :groups_association_name, + :ignore_base_class_inference_errors, + :ignore_association_class_inference_errors - self.group_class_name = 'Group' self.group_membership_class_name = 'GroupMembership' + self.group_class_name = nil # 'Group' + self.member_class_name = nil # 'User' + # Set to `false` if default association should not be created + self.members_association_name = false # :members + # Set to `false` if default association should not be created + self.groups_association_name = false # :groups + self.ignore_base_class_inference_errors = true + self.ignore_association_class_inference_errors = true def self.configure yield self end + def self.configure_legacy_defaults! + configure do |config| + config.group_class_name = 'Group' + config.member_class_name = 'User' + + config.groups_association_name = :groups + config.members_association_name = :members + end + end + def self.group_membership_klass group_membership_class_name.constantize end + + # Get the value of the superclass method. + # Return a default value if the result is `nil`. + def self.superclass_fetch(klass, method_name, default_value = nil, &default_value_builder) + # recursively try to get a non-nil value + while (klass = klass.superclass).method_defined?(method_name) + superclass_value = klass.__send__(method_name) + + return superclass_value unless superclass_value.nil? + end + + block_given? ? yield : default_value + end + + def self.infer_class_and_association_name(association_name) + begin + klass = association_name.to_s.classify.constantize + rescue NameError => ex + Rails.logger.warn "Groupify infer class error: #{ex.message}" + + if Groupify.ignore_association_class_inference_errors + klass = association_name.to_s.classify + end + end + + if !association_name.is_a?(Symbol) && klass.is_a?(Class) + association_name = klass.model_name.plural + end + + [klass, association_name.to_sym] + end + + def self.clean_membership_types(*membership_types) + membership_types.flatten.compact.map(&:to_s).reject(&:empty?) + end end require 'groupify/railtie' if defined?(Rails) diff --git a/lib/groupify/adapter/active_record.rb b/lib/groupify/adapter/active_record.rb index e8d9da1..0a357e9 100644 --- a/lib/groupify/adapter/active_record.rb +++ b/lib/groupify/adapter/active_record.rb @@ -8,7 +8,137 @@ module ActiveRecord autoload :Group, 'groupify/adapter/active_record/group' autoload :GroupMember, 'groupify/adapter/active_record/group_member' autoload :GroupMembership, 'groupify/adapter/active_record/group_membership' + autoload :PolymorphicCollection, 'groupify/adapter/active_record/polymorphic_collection' + autoload :PolymorphicRelation, 'groupify/adapter/active_record/polymorphic_relation' autoload :NamedGroupCollection, 'groupify/adapter/active_record/named_group_collection' autoload :NamedGroupMember, 'groupify/adapter/active_record/named_group_member' + + def self.is_db?(*strings) + strings.any?{ |string| ::ActiveRecord::Base.connection.adapter_name.downcase.include?(string) } + end + + def self.quote(column_name, model_class = nil) + model_class = Groupify.group_membership_klass unless model_class.is_a?(Class) + "#{model_class.quoted_table_name}.#{model_class.connection.quote_column_name(column_name)}" + end + + def self.prepare_concat(*columns) + options = columns.extract_options! + columns.flatten! + + if options[:quote] + columns = columns.map{ |column| quote(column, options[:quote]) } + end + + is_db?('sqlite') ? columns.join(' || ') : "CONCAT(#{columns.join(', ')})" + end + + def self.prepare_distinct(*columns) + options = columns.extract_options! + columns.flatten! + + if options[:quote] + columns = columns.map{ |column| quote(column, options[:quote]) } + end + + # Workaround to "group by" multiple columns in PostgreSQL + is_db?('postgres') ? "ON (#{columns.join(', ')}) *" : columns + end + + # Pass in record, class, or string + def self.base_class_name(model_class, default_base_class = nil) + return if model_class.nil? + + if model_class.is_a?(::ActiveRecord::Base) + model_class = model_class.class + elsif !(model_class.is_a?(Class) && model_class < ::ActiveRecord::Base) + model_class = model_class.to_s.constantize + end + + model_class.base_class.name + rescue NameError + return base_class_name(default_base_class) if default_base_class + return model_class.to_s if Groupify.ignore_base_class_inference_errors + + raise + end + + def self.create_children_association(klass, association_name, opts = {}, &extension) + association_class = opts[:class_name] + + unless association_class + association_class, association_name = Groupify.infer_class_and_association_name(association_name) + end + + default_base_class = opts.delete(:default_base_class) + model_klass = association_class || default_base_class + + # only try to look up base class if needed - can cause circular dependency issue + opts[:source_type] ||= ActiveRecord.base_class_name(model_klass, default_base_class) + opts[:class_name] ||= model_klass.to_s unless opts[:source_type].to_s == model_klass.to_s + + require 'groupify/adapter/active_record/association_extensions' + + klass.has_many association_name, ->{ distinct }, { + extend: Groupify::ActiveRecord::AssociationExtensions + }.merge(opts), &extension + + model_klass + + rescue NameError => ex + re = /has_(group|member)/ + line = ex.backtrace.find{ |i| i =~ re } + + message = ["Can't infer base class for #{parent_klass.inspect}: #{ex.message}. Try specifying the `:source_type` option"] + message << "such as `#{line.match(re)[0]}(#{association_name.inspect}, source_type: 'BaseClass')`" if line + message << "in case there is a circular dependency." + + raise message.join(' ') + end + + # Returns `false` if this is not an association + def self.group_memberships_association_name_for_association(scope) + find_association_name_through_group_memberships(scope).last + rescue ArgumentError + false + end + + # Finds the association name that goes through group memberships. + # e.g. [:members, :group_memberships_as_group] + def self.find_association_name_through_group_memberships(scope) + case scope + when ::ActiveRecord::Associations::CollectionProxy, ::ActiveRecord::AssociationRelation + scope_reflection = scope.proxy_association.reflection + previous_reflection = nil + + loop do + break if scope_reflection.nil? + + case scope_reflection.name + when :group_memberships_as_group, :group_memberships_as_member + break + end + + previous_reflection = scope_reflection + scope_reflection = scope_reflection.through_reflection + end + + [previous_reflection && previous_reflection.name, scope_reflection && scope_reflection.name] + else + raise ArgumentError, "The specified `scope` is not valid" + end + end + + class InvalidAssociationError < StandardError + end + + def self.check_group_memberships_for_association!(scope) + association_name = group_memberships_association_name_for_association(scope) + + return association_name unless association_name.nil? + + association_example = "#{scope.proxy_association.owner.class}##{scope.proxy_association.reflection.name}" + raise InvalidAssociationError, "You can't use the #{association_example} association because it does not go through the group memberships association." + end end end diff --git a/lib/groupify/adapter/active_record/association_extensions.rb b/lib/groupify/adapter/active_record/association_extensions.rb new file mode 100644 index 0000000..c902a0a --- /dev/null +++ b/lib/groupify/adapter/active_record/association_extensions.rb @@ -0,0 +1,30 @@ +require 'groupify/adapter/active_record/collection_extensions' + +module Groupify + module ActiveRecord + module AssociationExtensions + include CollectionExtensions + + def owner + proxy_association.owner + end + + def source_name + ActiveRecord.check_group_memberships_for_association!(self) == :group_memberships_as_group ? :member : :group + end + + protected + + # Throw an exception here when adding direction to an association + # because when adding the children to the parent this won't + # happen because the group membership is polymorphic. + def add_children(children, opts = {}) + children.each do |child| + proxy_association.__send__(:raise_on_type_mismatch!, child) + end + + super + end + end + end +end diff --git a/lib/groupify/adapter/active_record/collection_extensions.rb b/lib/groupify/adapter/active_record/collection_extensions.rb new file mode 100644 index 0000000..a7e4221 --- /dev/null +++ b/lib/groupify/adapter/active_record/collection_extensions.rb @@ -0,0 +1,56 @@ +module Groupify + module ActiveRecord + module CollectionExtensions + def delete(*records) + remove_children(records, :destroy, records.extract_options![:as]) + end + + def destroy(*records) + remove_children(records, :destroy, records.extract_options![:as]) + end + + # Defined to create alias methods before + # the association is extended with this module + def <<(*children) + opts = children.extract_options!.merge(exception_on_invalidation: false) + add_children(children.flatten, opts) + end + + def add(*children) + opts = children.extract_options!.merge(exception_on_invalidation: true) + add_children(children.flatten, opts) + end + + def collection + self + end + + def owner + raise "Not implemented" + end + + def source_name + raise "Not implemented" + end + + protected + + def add_children(children, opts = {}) + owner.__send__(:"add_#{source_name}s", children, opts) + end + + def remove_children(children, destruction_type, membership_type = nil) + owner. + __send__(:"find_memberships_for_#{source_name}s", children). + as(membership_type). + __send__(:"#{destruction_type}_all") + + owner.__send__(:clear_association_cache) + + children.each{|record| record.__send__(:clear_association_cache)} + + self + end + end + end +end diff --git a/lib/groupify/adapter/active_record/group.rb b/lib/groupify/adapter/active_record/group.rb index 46b9396..3eacdc3 100644 --- a/lib/groupify/adapter/active_record/group.rb +++ b/lib/groupify/adapter/active_record/group.rb @@ -13,34 +13,14 @@ module Group extend ActiveSupport::Concern included do - @default_member_class = nil - @member_klasses ||= Set.new - has_many :group_memberships_as_group, - dependent: :destroy, - as: :group, - class_name: Groupify.group_membership_class_name - - end - - def member_classes - self.class.member_classes + include Groupify::ActiveRecord::ModelExtensions.build_for(:group) + extend Groupify::ActiveRecord::ModelScopeExtensions.build_for(:group, child_methods: true) end - def add(*args) - opts = args.extract_options! - membership_type = opts[:as] - members = args.flatten - return unless members.present? + def add(*members) + opts = members.extract_options!.merge(exception_on_invalidation: true) - __send__(:clear_association_cache) - - members.each do |member| - member.groups << self unless member.groups.include?(self) - if membership_type - member.group_memberships_as_member.where(group_id: id, group_type: self.class.model_name.to_s, membership_type: membership_type).first_or_create! - end - member.__send__(:clear_association_cache) - end + add_members(members.flatten, opts) end # Merge a source group into this group. @@ -50,109 +30,30 @@ def merge!(source) module ClassMethods def with_member(member) - member.groups - end - - def default_member_class - @default_member_class ||= (User rescue false) - end - - def default_member_class=(klass) - @default_member_class = klass - end - - # Returns the member classes defined for this class, as well as for the super classes - def member_classes - (@member_klasses ||= Set.new).merge(superclass.method_defined?(:member_classes) ? superclass.member_classes : []) - end - - # Define which classes are members of this group - def has_members(*names) - Array.wrap(names.flatten).each do |name| - klass = name.to_s.classify.constantize - register(klass) - end + with_members(member) end # Merge two groups. The members of the source become members of the destination, and the source is destroyed. def merge!(source_group, destination_group) # Ensure that all the members of the source can be members of the destination - invalid_member_classes = (source_group.member_classes - destination_group.member_classes) - invalid_member_classes.each do |klass| - if klass.joins(:group_memberships_as_member).merge(Groupify.group_membership_klass.where(group_id: source_group)).count > 0 - raise ArgumentError.new("#{source_group.class} has members that cannot belong to #{destination_group.class}") - end - end - - source_group.transaction do - source_group.group_memberships_as_group.update_all(:group_id => destination_group.id) - source_group.destroy - end - end - - protected - - def register(member_klass) - (@member_klasses ||= Set.new) << member_klass - - associate_member_class(member_klass) - - member_klass - end - - module MemberAssociationExtensions - def as(membership_type) - merge(Groupify.group_membership_klass.as(membership_type)) - end - - def delete(*args) - opts = args.extract_options! - members = args + invalid_member_classes = source_group.member_classes - destination_group.member_classes + invalid_found = invalid_member_classes.any?{ |klass| klass.with_groups(source_group).count > 0 } - if opts[:as] - proxy_association.owner.group_memberships_as_group. - where(member_id: members.map(&:id), member_type: proxy_association.reflection.options[:source_type]). - as(opts[:as]). - delete_all - else - super(*members) - end + if invalid_found + raise ArgumentError.new("#{source_group.class} has members that cannot belong to #{destination_group.class}") end - def destroy(*args) - opts = args.extract_options! - members = args - - if opts[:as] - proxy_association.owner.group_memberships_as_group. - where(member_id: members.map(&:id), member_type: proxy_association.reflection.options[:source_type]). - as(opts[:as]). - destroy_all - else - super(*members) - end - end - end - - def associate_member_class(member_klass) - define_member_association(member_klass) + source_group.transaction do + source_group.group_memberships_as_group.update_all( + group_id: destination_group.id, + group_type: ActiveRecord.base_class_name(destination_group) + ) - if member_klass == default_member_class - define_member_association(member_klass, :members) + destination_group.__send__(:clear_association_cache) + source_group.__send__(:clear_association_cache) + source_group.destroy end end - - def define_member_association(member_klass, association_name = nil) - association_name ||= member_klass.model_name.plural.to_sym - source_type = member_klass.base_class - - has_many association_name, - ->{ distinct }, - through: :group_memberships_as_group, - source: :member, - source_type: source_type.to_s, - extend: MemberAssociationExtensions - end end end end diff --git a/lib/groupify/adapter/active_record/group_member.rb b/lib/groupify/adapter/active_record/group_member.rb index 9d4e0c9..547bacb 100644 --- a/lib/groupify/adapter/active_record/group_member.rb +++ b/lib/groupify/adapter/active_record/group_member.rb @@ -13,136 +13,78 @@ module GroupMember extend ActiveSupport::Concern included do - unless respond_to?(:group_memberships_as_member) - has_many :group_memberships_as_member, - as: :member, - autosave: true, - dependent: :destroy, - class_name: Groupify.group_membership_class_name - end - - has_many :groups, ->{ distinct }, - through: :group_memberships_as_member, - as: :group, - source_type: @group_class_name, - extend: GroupAssociationExtensions + include Groupify::ActiveRecord::ModelExtensions.build_for(:group_member) + extend Groupify::ActiveRecord::ModelScopeExtensions.build_for(:group_member, child_methods: true) end - module GroupAssociationExtensions - def as(membership_type) - return self unless membership_type - merge(Groupify.group_membership_klass.as(membership_type)) - end - - def delete(*args) - opts = args.extract_options! - groups = args.flatten - - if opts[:as] - proxy_association.owner.group_memberships_as_member.where(group_id: groups.map(&:id)).as(opts[:as]).delete_all - else - super(*groups) - end - end - - def destroy(*args) - opts = args.extract_options! - groups = args.flatten - - if opts[:as] - proxy_association.owner.group_memberships_as_member.where(group_id: groups.map(&:id)).as(opts[:as]).destroy_all - else - super(*groups) - end - end - end - - def in_group?(group, opts={}) + def in_group?(group, opts = {}) return false unless group.present? - criteria = {group_id: group.id} - - if opts[:as] - criteria.merge!(membership_type: opts[:as]) - end - group_memberships_as_member.exists?(criteria) + group_memberships_as_member. + for_groups(group). + as(opts[:as]). + exists? end - def in_any_group?(*args) - opts = args.extract_options! - groups = args - - groups.flatten.each do |group| - return true if in_group?(group, opts) - end - return false + def in_any_group?(*groups) + opts = groups.extract_options! + groups.flatten.any?{ |group| in_group?(group, opts) } end - def in_all_groups?(*args) - opts = args.extract_options! - groups = args.flatten - - groups.to_set.subset? self.groups.as(opts[:as]).to_set + def in_all_groups?(*groups) + membership_type = groups.extract_options![:as] + groups.flatten.to_set.subset? self.polymorphic_groups.as(membership_type).to_set end - def in_only_groups?(*args) - opts = args.extract_options! - groups = args.flatten - - groups.to_set == self.groups.as(opts[:as]).to_set + def in_only_groups?(*groups) + membership_type = groups.extract_options![:as] + groups.flatten.to_set == self.polymorphic_groups.as(membership_type).to_set end - def shares_any_group?(other, opts={}) - in_any_group?(other.groups, opts) + def shares_any_group?(other, opts = {}) + in_any_group?(other.polymorphic_groups, opts) end module ClassMethods - def as(membership_type) - joins(:group_memberships_as_member).merge(Groupify.group_membership_klass.as(membership_type)) - end - def in_group(group) - return none unless group.present? - - joins(:group_memberships_as_member).merge(Groupify.group_membership_klass.where(group_id: group)).distinct + group.present? ? with_groups(group).distinct : none end def in_any_group(*groups) - groups = groups.flatten - return none unless groups.present? - - joins(:group_memberships_as_member). - merge(Groupify.group_membership_klass.where(group_id: groups)). - distinct + groups.flatten! + groups.present? ? with_groups(groups).distinct : none end def in_all_groups(*groups) - groups = groups.flatten + groups.flatten! + return none unless groups.present? - joins(:group_memberships_as_member). - group("#{quoted_table_name}.#{connection.quote_column_name('id')}"). - merge(Groupify.group_membership_klass.where(group_id: groups)). - having("COUNT(DISTINCT #{Groupify.group_membership_klass.quoted_table_name}.#{connection.quote_column_name('group_id')}) = ?", groups.count). + # Count distinct on ID and type combo + concatenated_columns = ActiveRecord.prepare_concat('group_id', 'group_type', quote: true) + + with_groups(groups). + group(ActiveRecord.quote('id', self)). + having("COUNT(DISTINCT #{concatenated_columns}) = ?", groups.count). distinct end def in_only_groups(*groups) - groups = groups.flatten + groups.flatten! + return none unless groups.present? in_all_groups(*groups). - where.not(id: in_other_groups(*groups).select("#{quoted_table_name}.#{connection.quote_column_name('id')}")). + where.not(id: in_other_groups(*groups).select(ActiveRecord.quote('id', self))). distinct end def in_other_groups(*groups) - joins(:group_memberships_as_member). - merge(Groupify.group_membership_klass.where.not(group_id: groups)) + without_groups(groups) end def shares_any_group(other) - in_any_group(other.groups) + in_any_group(other.polymorphic_groups) end end end diff --git a/lib/groupify/adapter/active_record/group_membership.rb b/lib/groupify/adapter/active_record/group_membership.rb index 99837b3..c4d1053 100644 --- a/lib/groupify/adapter/active_record/group_membership.rb +++ b/lib/groupify/adapter/active_record/group_membership.rb @@ -13,12 +13,12 @@ module GroupMembership extend ActiveSupport::Concern included do - belongs_to :member, polymorphic: true - belongs_to :group, polymorphic: true + belongs_to :member, polymorphic: true, inverse_of: :group_memberships_as_member + belongs_to :group, polymorphic: true, inverse_of: :group_memberships_as_group, required: false end def membership_type=(membership_type) - self[:membership_type] = membership_type.to_s if membership_type.present? + super(membership_type.to_s) if membership_type.present? end def as=(membership_type) @@ -30,7 +30,7 @@ def as end module ClassMethods - def named(group_name=nil) + def named(group_name = nil) if group_name.present? where(group_name: group_name) else @@ -38,8 +38,78 @@ def named(group_name=nil) end end - def as(membership_type) - where(membership_type: membership_type) + def as(*membership_types) + membership_types = Groupify.clean_membership_types(membership_types) + + membership_types.any? ? where(membership_type: membership_types) : all + end + + def polymorphic_groups + PolymorphicCollection.new(:group) + end + + def polymorphic_members + PolymorphicCollection.new(:member) + end + + def for_groups(groups) + for_polymorphic(:group, groups) + end + + def not_for_groups(groups) + where.not(build_polymorphic_criteria_for(:group, groups)) + end + + def for_members(members) + for_polymorphic(:member, members) + end + + def not_for_members(groups) + where.not(build_polymorphic_criteria_for(:member, members)) + end + + def for_polymorphic(source, records, opts = {}) + case records + when Array + where(build_polymorphic_criteria_for(source, records)) + when ::ActiveRecord::Relation + all.where(source => records) + when ::ActiveRecord::Base + # Nasty bug causes wrong results in Rails 4.2 + records = records.reload if ::ActiveRecord.version < Gem::Version.new("5.0.0") + all.merge(records.__send__(:"group_memberships_as_#{source}")) + else + all + end + end + + # Build criteria to search on ID grouped by base class type. + # This is for polymorphic associations where the ID may be from + # different tables. + def build_polymorphic_criteria_for(source, records) + case records + when ::ActiveRecord::Relation + { + :"#{source}_type" => ActiveRecord.base_class_name(records.klass), + :"#{source}_id" => records.select(:id) + } + else + id_column, type_column = arel_table[:"#{source}_id"], arel_table[:"#{source}_type"] + records_by_base_class = records.group_by{ |record| ActiveRecord.base_class_name(record) } + + criteria = records_by_base_class.map do |type, grouped_records| + arel_table.grouping( + type_column.eq(type). + and( + id_column.in(grouped_records.map(&:id)) + ) + ) + end + + # Generates something like: + # (group_type = `Group` AND group_id IN (?)) OR (group_type = `Team` AND group_id IN(?)) + criteria.reduce(:or) + end end end end diff --git a/lib/groupify/adapter/active_record/model.rb b/lib/groupify/adapter/active_record/model.rb index 27e6ff9..664d4c2 100644 --- a/lib/groupify/adapter/active_record/model.rb +++ b/lib/groupify/adapter/active_record/model.rb @@ -4,6 +4,9 @@ module Model extend ActiveSupport::Concern included do + require 'groupify/adapter/active_record/model_scope_extensions' + require 'groupify/adapter/active_record/model_extensions' + # Define a scope that returns nothing. # This is built into ActiveRecord 4, but not 3 unless self.class.respond_to? :none @@ -21,18 +24,13 @@ def groupify(type, opts = {}) def acts_as_group(opts = {}) include Groupify::ActiveRecord::Group - if (member_klass = opts.delete :default_members) - self.default_member_class = member_klass.to_s.classify.constantize - end - - if (member_klasses = opts.delete :members) - has_members(member_klasses) - end + configure_group!(opts) end def acts_as_group_member(opts = {}) - @group_class_name = opts[:group_class_name] || Groupify.group_class_name include Groupify::ActiveRecord::GroupMember + + configure_group_member!(opts) end def acts_as_named_group_member(opts = {}) diff --git a/lib/groupify/adapter/active_record/model_extensions.rb b/lib/groupify/adapter/active_record/model_extensions.rb new file mode 100644 index 0000000..97547dc --- /dev/null +++ b/lib/groupify/adapter/active_record/model_extensions.rb @@ -0,0 +1,212 @@ +module Groupify + module ActiveRecord + module ModelExtensions + def self.build_for(official_parent_type, options = {}) + module_name = "#{official_parent_type.to_s.classify}ModelExtensions" + + const_get(module_name.to_sym) + rescue NameError + # convert :group_member and :named_group_member + parent_type, child_type = official_parent_type == :group ? [:group, :member] : [:member, :group] + + new_module = Module.new do + extend ActiveSupport::Concern + + class_eval <<-CODE, __FILE__ , __LINE__ + 1 + included do + @default_#{child_type}_class_name = nil + @default_#{child_type}s_association_name = nil + @#{child_type}_klasses ||= Set.new + + has_many :group_memberships_as_#{parent_type}, + as: :#{parent_type}, + inverse_of: :#{parent_type}, + autosave: true, + dependent: :destroy, + class_name: Groupify.group_membership_class_name + end + + module ClassMethods + def configure_#{official_parent_type}!(opts = {}) + # Get defaults from parent class for STI + self.default_#{child_type}_class_name = Groupify.superclass_fetch(self, :default_#{child_type}_class_name, Groupify.#{child_type}_class_name) + self.default_#{child_type}s_association_name = Groupify.superclass_fetch(self, :default_#{child_type}s_association_name, Groupify.#{child_type}s_association_name) + + if (association_names = opts.delete :#{child_type}s) + has_#{child_type}s(association_names) + end + + if (default_association_name = opts.delete :default_#{child_type}s) + self.default_#{child_type}_class_name = default_association_name.to_s.classify + # Only use as the association name if none specified (backwards-compatibility) + self.default_#{child_type}s_association_name ||= default_association_name + end + + if (default_class_name = opts.delete :#{child_type}_class_name) + self.default_#{child_type}_class_name = default_class_name + end + + if self.default_#{child_type}s_association_name + has_#{child_type}(self.default_#{child_type}s_association_name, + source_type: ActiveRecord.base_class_name(self.default_#{child_type}_class_name), + class_name: self.default_#{child_type}_class_name + ) + end + end + + def default_#{child_type}_class_name + @default_#{child_type}_class_name ||= Groupify.#{child_type}_class_name + end + + def default_#{child_type}_class_name=(klass) + @default_#{child_type}_class_name = klass + end + + def default_#{child_type}s_association_name + @default_#{child_type}s_association_name ||= Groupify.#{child_type}s_association_name + end + + def default_#{child_type}s_association_name=(name) + @default_#{child_type}s_association_name = name && name.to_sym + end + + # Returns the #{child_type} classes defined for this class, as well as for the super classes + def #{child_type}_classes + (@#{child_type}_klasses ||= Set.new).merge(Groupify.superclass_fetch(self, :#{child_type}_classes, [])) + end + + def has_#{child_type}s(*association_names, &extension) + association_names.flatten.each do |association_name| + has_#{child_type}(association_name, &extension) + end + end + + def has_#{child_type}(association_name, opts = {}, &extension) + #{child_type}_klass = ActiveRecord.create_children_association(self, association_name, + opts.merge( + through: :group_memberships_as_#{parent_type}, + source: :#{child_type}, + default_base_class: self.default_#{child_type}_class_name + ), + &extension + ) + + (@#{child_type}_klasses ||= Set.new) << #{child_type}_klass.to_s.constantize + rescue NameError + Rails.logger.warn "Error: Unable to add \#{#{child_type}_klass} to @#{child_type}_klasses" + ensure + self + end + end + + def polymorphic_#{child_type}s(&group_membership_filter) + PolymorphicRelation.new(self, :#{child_type}, &group_membership_filter) + end + + def #{child_type}_classes + self.class.#{child_type}_classes + end + + # returns `nil` membership type with results + def membership_types_for_#{child_type}(record) + group_memberships_as_#{parent_type}. + for_#{child_type}s([record]). + select(:membership_type). + distinct. + pluck(:membership_type). + sort_by(&:to_s) + end + + def find_memberships_for_#{child_type}s(children) + group_memberships_as_#{parent_type}.for_#{child_type}s(children) + end + + def add_#{child_type}s(children, opts = {}) + return self if children.none? + + clear_association_cache_for(self) + + membership_type = opts[:as] + + to_add_directly = [] + to_add_with_membership_type = [] + + already_children = find_memberships_for_#{child_type}s(children).includes(:#{child_type}).group_by{ |membership| membership.#{child_type} } + + # first prepare changes + children.each do |child| + # add to collection without membership type + unless already_children[child] && already_children[child].find{ |m| m.membership_type.nil? } + to_add_directly << group_memberships_as_#{parent_type}.build(#{child_type}: child) + end + + # add a second entry for the given membership type + if membership_type.present? + membership = group_memberships_as_#{parent_type}. + merge(child.group_memberships_as_#{child_type}). + as(membership_type). + first_or_initialize + to_add_with_membership_type << membership unless membership.persisted? + end + + clear_association_cache_for(child) + end + + clear_association_cache_for(self) + + # then validate changes + list_to_validate = to_add_directly + to_add_with_membership_type + + list_to_validate.each do |child| + next if child.valid? + + if opts[:exception_on_invalidation] + raise ::ActiveRecord::RecordInvalid.new(child) + else + return false + end + end + + # create memberships without membership type + assign_memberships_to_#{parent_type}(self, *to_add_directly) + + # create memberships with membership type + to_add_with_membership_type. + group_by{ |membership| membership.#{parent_type} }. + each do |membership_parent, memberships| + assign_memberships_to_#{parent_type}(membership_parent, *memberships) + clear_association_cache_for(membership_parent) + end + + self + end + + protected + + def assign_memberships_to_#{parent_type}(target, *memberships) + memberships.flatten! + + target.group_memberships_as_#{parent_type} << memberships + + return if target.persisted? + + memberships.each do |membership| + unless membership.#{child_type}.group_memberships_as_#{child_type}.include?(membership) + membership.#{child_type}.group_memberships_as_#{child_type} << membership + end + end + end + CODE + + protected + + def clear_association_cache_for(record) + record.__send__(:clear_association_cache) + end + end + + self.const_set(module_name, new_module) + end + end + end +end diff --git a/lib/groupify/adapter/active_record/model_scope_extensions.rb b/lib/groupify/adapter/active_record/model_scope_extensions.rb new file mode 100644 index 0000000..3f19c75 --- /dev/null +++ b/lib/groupify/adapter/active_record/model_scope_extensions.rb @@ -0,0 +1,101 @@ +module Groupify + module ActiveRecord + module ModelScopeExtensions + def self.build_for(parent_type, options = {}) + module_name = "#{parent_type.to_s.classify}ScopeExtensions" + + const_get(module_name.to_sym) + rescue NameError + # convert :group_member and :named_group_member + parent_type = :member unless parent_type == :group + child_type = parent_type == :group ? :member : :group + + new_module = Module.new do + # This is an ambiguous call when a class implements both group and + # member. We make a guess, but default to assuming it's a member. + # See `detect_result_type_for` for more details. + def as(*membership_types) + if detect_result_type_for(current_scope || self) == :member + with_memberships_for_member{as(*membership_types)} + else + with_memberships_for_group{as(*membership_types)} + end + end + + base_methods = %Q( + def with_memberships_for_#{parent_type}(opts = {}, &group_membership_filter) + criteria = [] + criteria << joins(:group_memberships_as_#{parent_type}) + criteria << opts[:criteria] if opts[:criteria] + criteria << Groupify.group_membership_klass.instance_eval(&group_membership_filter) if block_given? + + # merge all criteria together + criteria.compact.reduce(:merge) + end + ) + + child_methods = %Q( + def with_#{child_type}s(child_or_children) + scope = case child_or_children + when ::ActiveRecord::Base + # single child + with_memberships_for_#{parent_type}(criteria: child_or_children.group_memberships_as_#{child_type}) + else + with_memberships_for_#{parent_type}{for_#{child_type}s(child_or_children)} + end + + if block_given? + scope = scope.with_memberships_for_#{parent_type}(&group_membership_filter) + end + + scope.distinct + end + + def without_#{child_type}s(children) + with_memberships_for_#{parent_type}{not_for_#{child_type}s(children)} + end + ) + + class_eval(base_methods) + class_eval(child_methods) if options[:child_methods] + + protected + + # Determines what the result type is for the scope (group or member). + # If it implements both, then we see if we can infer things from joins. + # Defaults to assume it's a group. + def detect_result_type_for(scope) + group_memberships_association_name = ActiveRecord.check_group_memberships_for_association!(scope) + + if group_memberships_association_name + return group_memberships_association_name == :group_memberships_as_group ? :member : :group + end + + case scope + when Class # assume inherits ::ActiveRecord::Base + klass = scope + when ::ActiveRecord::Base + klass = scope.class + when ::ActiveRecord::Relation + klass = scope.klass + end + + types = [] + types << :group if klass < Group + types << :member if klass < GroupMember || klass < NamedGroupMember + + return types.first if types.one? + + if scope.is_a?(::ActiveRecord::Relation) && scope.joins_values.first == :group_memberships_as_group + :group + else + :member + end + end + end + + const_set(module_name, new_module) + end + end + end +end diff --git a/lib/groupify/adapter/active_record/named_group_collection.rb b/lib/groupify/adapter/active_record/named_group_collection.rb index 05ff688..6d1afcf 100644 --- a/lib/groupify/adapter/active_record/named_group_collection.rb +++ b/lib/groupify/adapter/active_record/named_group_collection.rb @@ -6,26 +6,22 @@ def initialize(member) @member = member @named_group_memberships = member.group_memberships_as_member.named @group_names = @named_group_memberships.pluck(:group_name).map(&:to_sym) + super(@group_names) end - def add(named_group, opts={}) + def add(named_group, opts = {}) named_group = named_group.to_sym - membership_type = opts[:as] - - if @member.new_record? - @member.group_memberships_as_member.build(group_name: named_group, membership_type: nil) - else - @member.transaction do - @member.group_memberships_as_member.where(group_name: named_group, membership_type: nil).first_or_create! - end - end - - if membership_type - if @member.new_record? - @member.group_memberships_as_member.build(group_name: named_group, membership_type: membership_type) - else - @member.group_memberships_as_member.where(group_name: named_group, membership_type: membership_type).first_or_create! + membership_types = Groupify.clean_membership_types(opts[:as]) + membership_types << nil # add default membership + + @member.transaction do + membership_types.each do |membership_type| + if @member.new_record? + @member.group_memberships_as_member.build(group_name: named_group, membership_type: membership_type) + else + @member.group_memberships_as_member.where(group_name: named_group, membership_type: membership_type).first_or_create! + end end end @@ -35,18 +31,19 @@ def add(named_group, opts={}) alias_method :push, :add alias_method :<<, :add - def merge(*args) - opts = args.extract_options! - named_groups = args.flatten - named_groups.each do |named_group| + def merge(*named_groups) + opts = named_groups.extract_options! + + named_groups.flatten.each do |named_group| add(named_group, opts) end end alias_method :concat, :merge - def include?(named_group, opts={}) + def include?(named_group, opts = {}) named_group = named_group.to_sym + if opts[:as] as(opts[:as]).include?(named_group) else @@ -54,18 +51,16 @@ def include?(named_group, opts={}) end end - def delete(*args) - opts = args.extract_options! - named_groups = args.flatten.compact + def delete(*named_groups) + membership_type = named_groups.extract_options![:as] - remove(named_groups, :delete_all, opts) + remove(named_groups.flatten.compact, :delete_all, membership_type) end - def destroy(*args) - opts = args.extract_options! - named_groups = args.flatten.compact + def destroy(*named_groups) + membership_type = named_groups.extract_options![:as] - remove(named_groups, :destroy_all, opts) + remove(named_groups.flatten.compact, :destroy_all, membership_type) end def clear @@ -77,27 +72,32 @@ def clear alias_method :destroy_all, :clear # Criteria to filter by membership type - def as(membership_type) - @named_group_memberships.as(membership_type).pluck(:group_name).map(&:to_sym) + def as(*membership_types) + membership_types = Groupify.clean_membership_types(membership_types) + + if membership_types.any? + @named_group_memberships.as(membership_types).pluck(:group_name).map(&:to_sym) + else + to_a.map(&:to_sym) + end end - protected + protected - def remove(named_groups, method, opts) - if named_groups.present? - scope = @named_group_memberships.where(group_name: named_groups) + def remove(named_groups, destruction_type, membership_type = nil) + return unless named_groups.present? - if opts[:as] - scope = scope.where(membership_type: opts[:as]) - end + membership_types = Groupify.clean_membership_types(membership_type) - scope.send(method) + (@named_group_memberships. + where(group_name: named_groups). + as(membership_types). + __send__(destruction_type)) - unless opts[:as] - named_groups.each do |named_group| - @hash.delete(named_group) - end - end + return if membership_types.any? + + named_groups.each do |named_group| + @hash.delete(named_group) end end end diff --git a/lib/groupify/adapter/active_record/named_group_member.rb b/lib/groupify/adapter/active_record/named_group_member.rb index b7d22f7..266dc3d 100644 --- a/lib/groupify/adapter/active_record/named_group_member.rb +++ b/lib/groupify/adapter/active_record/named_group_member.rb @@ -13,13 +13,13 @@ module NamedGroupMember extend ActiveSupport::Concern included do - unless respond_to?(:group_memberships_as_member) - has_many :group_memberships_as_member, - as: :member, - autosave: true, - dependent: :destroy, - class_name: Groupify.group_membership_class_name - end + extend Groupify::ActiveRecord::ModelScopeExtensions.build_for(:named_group_member) + + has_many :group_memberships_as_member, + as: :member, + autosave: true, + dependent: :destroy, + class_name: Groupify.group_membership_class_name end def named_groups @@ -32,62 +32,61 @@ def named_groups=(named_groups) end end - def in_named_group?(named_group, opts={}) + # returns `nil` membership type with results + def membership_types_for_named_group(named_group) + group_memberships_as_member. + where(group_name: named_group). + select(:membership_type). + distinct. + pluck(:membership_type). + sort_by(&:to_s) + end + + def in_named_group?(named_group, opts = {}) named_groups.include?(named_group, opts) end - def in_any_named_group?(*args) - opts = args.extract_options! - named_groups = args.flatten - named_groups.each do |named_group| - return true if in_named_group?(named_group, opts) - end - return false + def in_any_named_group?(*named_groups) + opts = named_groups.extract_options! + named_groups.flatten.any?{ |named_group| in_named_group?(named_group, opts) } end - def in_all_named_groups?(*args) - opts = args.extract_options! - named_groups = args.flatten.to_set - named_groups.subset? self.named_groups.as(opts[:as]).to_set + def in_all_named_groups?(*named_groups) + membership_type = named_groups.extract_options![:as] + named_groups.flatten.to_set.subset? self.named_groups.as(membership_type).to_set end - def in_only_named_groups?(*args) - opts = args.extract_options! - named_groups = args.flatten.to_set - named_groups == self.named_groups.as(opts[:as]).to_set + def in_only_named_groups?(*named_groups) + membership_type = named_groups.extract_options![:as] + named_groups.flatten.to_set == self.named_groups.as(membership_type).to_set end - def shares_any_named_group?(other, opts={}) + def shares_any_named_group?(other, opts = {}) in_any_named_group?(other.named_groups.to_a, opts) end module ClassMethods - def as(membership_type) - joins(:group_memberships_as_member).merge(Groupify.group_membership_klass.as(membership_type)) - end - def in_named_group(named_group) return none unless named_group.present? - joins(:group_memberships_as_member).merge(Groupify.group_membership_klass.where(group_name: named_group)).distinct + with_memberships_for_member{where(group_name: named_group)}.distinct end def in_any_named_group(*named_groups) named_groups.flatten! return none unless named_groups.present? - joins(:group_memberships_as_member).merge(Groupify.group_membership_klass.where(group_name: named_groups.flatten)).distinct + with_memberships_for_member{where(group_name: named_groups.flatten)}.distinct end def in_all_named_groups(*named_groups) named_groups.flatten! return none unless named_groups.present? - joins(:group_memberships_as_member). - group("#{quoted_table_name}.#{connection.quote_column_name('id')}"). - merge(Groupify.group_membership_klass.where(group_name: named_groups)). - having("COUNT(DISTINCT #{Groupify.group_membership_klass.quoted_table_name}.#{connection.quote_column_name('group_name')}) = ?", named_groups.count). - distinct + with_memberships_for_member{where(group_name: named_groups)}. + group(ActiveRecord.quote('id', self)). + having("COUNT(DISTINCT #{ActiveRecord.quote('group_name')}) = ?", named_groups.count). + distinct end def in_only_named_groups(*named_groups) @@ -95,13 +94,12 @@ def in_only_named_groups(*named_groups) return none unless named_groups.present? in_all_named_groups(*named_groups). - where.not(id: in_other_named_groups(*named_groups).select("#{quoted_table_name}.#{connection.quote_column_name('id')}")). + where.not(id: in_other_named_groups(*named_groups).select(ActiveRecord.quote('id', self))). distinct end def in_other_named_groups(*named_groups) - joins(:group_memberships_as_member). - merge(Groupify.group_membership_klass.where.not(group_name: named_groups)) + with_memberships_for_member{where.not(group_name: named_groups)} end def shares_any_named_group(other) diff --git a/lib/groupify/adapter/active_record/polymorphic_collection.rb b/lib/groupify/adapter/active_record/polymorphic_collection.rb new file mode 100644 index 0000000..fc4f697 --- /dev/null +++ b/lib/groupify/adapter/active_record/polymorphic_collection.rb @@ -0,0 +1,68 @@ +module Groupify + module ActiveRecord + # This `PolymorphicCollection` class acts as a facade to mimic the querying + # capabilities of an ActiveRecord::Relation while internally returning results + # which are actually retrieved from a method or association on the actual + # results. In other words, this class queries on the "join record" + # and returns records from one of the associations that would have + # to otherwise query across multiple tables. To avoid N+1, `includes` + # is added to the query chain to make things more efficient. + class PolymorphicCollection + include Enumerable + extend Forwardable + + attr_reader :source + + def initialize(source_name, &group_membership_filter) + @source_name = source_name + @collection = build_collection(&group_membership_filter) + end + + def each(&block) + distinct_compat.map do |group_membership| + group_membership.__send__(@source_name).tap(&block) + end + end + + def_delegators :@collection, :reload + + def count + @collection.loaded? ? @collection.size : count_compat + end + + alias_method :size, :count + + def_delegators :to_a, :[], :pretty_print + + alias_method :to_ary, :to_a + alias_method :empty?, :none? + alias_method :blank?, :none? + + def inspect + "#<#{self.class}:0x#{self.__id__.to_s(16)} #{to_a.inspect}>" + end + + protected + + def build_collection(&group_membership_filter) + collection = Groupify.group_membership_klass.where.not(:"#{@source_name}_id" => nil) + collection = collection.instance_eval(&group_membership_filter) if block_given? + collection = collection.includes(@source_name) + + collection + end + + def distinct_compat + @collection.select(ActiveRecord.prepare_distinct(*distinct_columns)).distinct + end + + def count_compat + @collection.select(ActiveRecord.prepare_concat(*distinct_columns)).distinct.count + end + + def distinct_columns + [ActiveRecord.quote("#{@source_name}_id"), ActiveRecord.quote("#{@source_name}_type")] + end + end + end +end diff --git a/lib/groupify/adapter/active_record/polymorphic_relation.rb b/lib/groupify/adapter/active_record/polymorphic_relation.rb new file mode 100644 index 0000000..a5c14e9 --- /dev/null +++ b/lib/groupify/adapter/active_record/polymorphic_relation.rb @@ -0,0 +1,30 @@ +module Groupify + module ActiveRecord + # This class acts as an association facade building on `PolymorphicCollection` + # by implementing the Groupify helper methods on this collection. This class + # also mimics an association by tracking the parent record that owns the + # association. + class PolymorphicRelation < PolymorphicCollection + include CollectionExtensions + + attr_reader :collection + + def initialize(owner, source_name, &group_membership_filter) + @owner = owner + parent_type = source_name == :group ? :member : :group + + super(source_name) do + query = merge(owner.__send__(:"group_memberships_as_#{parent_type}")) + query = query.instance_eval(&group_membership_filter) if block_given? + query + end + end + + def as(*membership_types) + @collection = @collection.as(membership_types) + + self + end + end + end +end diff --git a/lib/groupify/adapter/mongoid/group.rb b/lib/groupify/adapter/mongoid/group.rb index 5ea992a..5883315 100644 --- a/lib/groupify/adapter/mongoid/group.rb +++ b/lib/groupify/adapter/mongoid/group.rb @@ -15,7 +15,8 @@ module Group extend ActiveSupport::Concern included do - @default_member_class = nil + @default_member_class_name = nil + @default_members_association_name = nil @member_klasses ||= Set.new end @@ -27,10 +28,10 @@ def member_classes self.class.member_classes end - def add(*args) - opts = args.extract_options! - membership_type = opts[:as] - members = args.flatten + def add(*members) + membership_type = members.extract_options![:as] + members.flatten! + return unless members.present? members.each do |member| @@ -61,37 +62,73 @@ def default_member_class=(klass) # Returns the member classes defined for this class, as well as for the super classes def member_classes - (@member_klasses ||= Set.new).merge(superclass.method_defined?(:member_classes) ? superclass.member_classes : []) + (@member_klasses ||= Set.new).merge(Groupify.superclass_fetch(self, :member_classes, [])) + end + + def default_member_class_name + @default_member_class_name ||= Groupify.member_class_name + end + + def default_member_class_name=(klass) + @default_member_class_name = klass + end + + def default_members_association_name + @default_members_association_name ||= Groupify.members_association_name + end + + def default_members_association_name=(name) + @default_members_association_name = name && name.to_sym end # Define which classes are members of this group - def has_members(*names) - Array.wrap(names.flatten).each do |name| - klass = name.to_s.classify.constantize - register(klass) + def has_members(*association_names) + association_names.flatten.each do |association_name| + has_member(association_name) end end + def has_member(association_name, opts = {}) + association_class, association_name = Groupify.infer_class_and_association_name(association_name) + model_klass = opts[:class_name] || association_class || default_member_class_name + member_klass = model_klass.to_s.constantize + + (@member_klasses ||= Set.new) << member_klass + + has_many association_name, { + class_name: member_klass.to_s, + dependent: :nullify, + foreign_key: 'group_ids', + extend: MemberAssociationExtensions + }.merge(opts) + + member_klass + end + # Merge two groups. The members of the source become members of the destination, and the source is destroyed. def merge!(source_group, destination_group) # Ensure that all the members of the source can be members of the destination - invalid_member_classes = (source_group.member_classes - destination_group.member_classes) - invalid_member_classes.each do |klass| - if klass.in(group_ids: [source_group.id]).count > 0 - raise ArgumentError.new("#{source_group.class} has members that cannot belong to #{destination_group.class}") - end + invalid_member_classes = source_group.member_classes - destination_group.member_classes + invalid_found = invalid_member_classes.any?{ |klass| klass.in(group_ids: [source_group.id]).count > 0 } + + if invalid_found + raise ArgumentError.new("#{source_group.class} has members that cannot belong to #{destination_group.class}") end source_group.member_classes.each do |klass| klass.in(group_ids: [source_group.id]).update_all(:$set => {:"group_ids.$" => destination_group.id}) if klass.relations['group_memberships'] + scope = klass.in(:"group_memberships.group_ids" => [source_group.id]) + criteria_for_add_to_set = {:"group_memberships.$.group_ids" => destination_group.id} + criteria_for_pull = {:"group_memberships.$.group_ids" => source_group.id} + if ::Mongoid::VERSION > "4" - klass.in(:"group_memberships.group_ids" => [source_group.id]).add_to_set(:"group_memberships.$.group_ids" => destination_group.id) - klass.in(:"group_memberships.group_ids" => [source_group.id]).pull(:"group_memberships.$.group_ids" => source_group.id) + scope.add_to_set(criteria_for_add_to_set) + scope.pull(criteria_for_pull) else - klass.in(:"group_memberships.group_ids" => [source_group.id]).add_to_set(:"group_memberships.$.group_ids", destination_group.id) - klass.in(:"group_memberships.group_ids" => [source_group.id]).pull(:"group_memberships.$.group_ids", source_group.id) + scope.add_to_set(*criteria_for_add_to_set.to_a.flatten) + scope.pull(*criteria_for_pull.to_a.flatten) end end end @@ -101,29 +138,21 @@ def merge!(source_group, destination_group) protected - def register(member_klass) - (@member_klasses ||= Set.new) << member_klass - associate_member_class(member_klass) - member_klass - end - module MemberAssociationExtensions def as(membership_type) - return self unless membership_type - where(:group_memberships.elem_match => { as: membership_type.to_s, group_ids: [base.id] }) + membership_type.present? ? where(:group_memberships.elem_match => {as: membership_type, group_ids: [base.id]}) : self end - def destroy(*args) - delete(*args) + def destroy(*members) + delete(*members) end - def delete(*args) - opts = args.extract_options! - members = args + def delete(*members) + membership_type = members.extract_options![:as] - if opts[:as] + if membership_type.present? members.each do |member| - member.group_memberships.as(opts[:as]).first.groups.delete(base) + member.group_memberships.as(membership_type).first.groups.delete(base) end else members.each do |member| @@ -136,16 +165,6 @@ def delete(*args) end end end - - def associate_member_class(member_klass) - association_name ||= member_klass.model_name.plural.to_sym - - has_many association_name, class_name: member_klass.to_s, dependent: :nullify, foreign_key: 'group_ids', extend: MemberAssociationExtensions - - if member_klass == default_member_class - has_many :members, class_name: member_klass.to_s, dependent: :nullify, foreign_key: 'group_ids', extend: MemberAssociationExtensions - end - end end end end diff --git a/lib/groupify/adapter/mongoid/group_member.rb b/lib/groupify/adapter/mongoid/group_member.rb index d65098c..29897d1 100644 --- a/lib/groupify/adapter/mongoid/group_member.rb +++ b/lib/groupify/adapter/mongoid/group_member.rb @@ -16,36 +16,8 @@ module GroupMember include MemberScopedAs included do - has_and_belongs_to_many :groups, autosave: true, dependent: :nullify, inverse_of: nil, class_name: @group_class_name do - def as(membership_type) - return self unless membership_type - group_ids = base.group_memberships.as(membership_type).first.group_ids - - if group_ids.present? - self.and(:id.in => group_ids) - else - self.and(:id => nil) - end - end - - def destroy(*args) - delete(*args) - end - - def delete(*args) - opts = args.extract_options! - groups = args.flatten - - - if opts[:as] - base.group_memberships.as(opts[:as]).each do |membership| - membership.groups.delete(*groups) - end - else - super(*groups) - end - end - end + @default_group_class_name = nil + @default_groups_association_name = nil class GroupMembership include ::Mongoid::Document @@ -61,45 +33,33 @@ class GroupMembership field :as, as: :membership_type, type: String end - GroupMembership.send :has_and_belongs_to_many, :groups, class_name: @group_class_name, inverse_of: nil - embeds_many :group_memberships, class_name: GroupMembership.to_s, as: :member do def as(membership_type) - where(membership_type: membership_type.to_s) + where(membership_type: membership_type) end end end - def in_group?(group, opts={}) - return false unless group.present? - groups.as(opts[:as]).include?(group) + def in_group?(group, opts = {}) + group.present? ? groups.as(opts[:as]).include?(group) : false end - def in_any_group?(*args) - opts = args.extract_options! - groups = args - - groups.flatten.each do |group| - return true if in_group?(group, opts) - end - return false + def in_any_group?(*groups) + opts = groups.extract_options! + groups.flatten.any?{ |group| in_group?(group, opts) } end - def in_all_groups?(*args) - opts = args.extract_options! - groups = args - - groups.flatten.to_set.subset? self.groups.as(opts[:as]).to_set + def in_all_groups?(*groups) + membership_type = groups.extract_options![:as] + groups.flatten.to_set.subset? self.groups.as(membership_type).to_set end - def in_only_groups?(*args) - opts = args.extract_options! - groups = args.flatten - - groups.to_set == self.groups.as(opts[:as]).to_set + def in_only_groups?(*groups) + membership_type = groups.extract_options![:as] + groups.to_set == self.groups.as(membership_type).to_set end - def shares_any_group?(other, opts={}) + def shares_any_group?(other, opts = {}) in_any_group?(other.groups.to_a, opts) end @@ -124,6 +84,72 @@ def shares_any_group(other) in_any_group(other.groups.to_a) end + def default_group_class_name + @default_group_class_name ||= Groupify.group_class_name + end + + def default_group_class_name=(klass) + @default_group_class_name = klass + end + + def default_groups_association_name + @default_groups_association_name ||= Groupify.groups_association_name + end + + def default_groups_association_name=(name) + @default_groups_association_name = name && name.to_sym + end + + def has_groups(*association_names) + association_names.flatten.each do |association_name| + has_group(association_name) + end + end + + def has_group(association_name, opts = {}) + association_class, association_name = Groupify.infer_class_and_association_name(association_name) + opts = {autosave: true, dependent: :nullify, inverse_of: nil}.merge(opts) + model_klass = opts[:class_name] || association_class || default_base_class + + has_and_belongs_to_many association_name, opts do + def as(membership_type) + # `membership_type.present?` causes tests to fail for `MongoidManager` class.... + return self unless membership_type + + group_ids = base.group_memberships.as(membership_type).first.group_ids + + if group_ids.present? + self.and(:id.in => group_ids) + else + self.and(:id => nil) + end + end + + def destroy(*groups) + delete(*groups) + end + + def delete(*groups) + membership_type = groups.extract_options![:as] + groups.flatten! + + if membership_type.present? + base.group_memberships.as(membership_type).each do |membership| + membership.groups.delete(*groups) + end + else + super(*groups) + end + end + end + + GroupMembership.send(:has_and_belongs_to_many, + association_name, { + class_name: model_klass, + inverse_of: nil}. + merge(opts.slice(:class_name)) + ) + end end end end diff --git a/lib/groupify/adapter/mongoid/member_scoped_as.rb b/lib/groupify/adapter/mongoid/member_scoped_as.rb index 4c37c55..1efaabd 100644 --- a/lib/groupify/adapter/mongoid/member_scoped_as.rb +++ b/lib/groupify/adapter/mongoid/member_scoped_as.rb @@ -6,21 +6,18 @@ module MemberScopedAs module ClassMethods def as(membership_type) + criteria = self.criteria + + return criteria unless membership_type.present? + group_ids = criteria.selector["group_ids"] named_groups = criteria.selector["named_groups"] - criteria = self.criteria # If filtering by groups or named groups, merge into the group membership criteria if group_ids || named_groups elem_match = {as: membership_type} - - if group_ids - elem_match.merge!(group_ids: group_ids) - end - - if named_groups - elem_match.merge!(named_groups: named_groups) - end + elem_match.merge!(group_ids: group_ids) if group_ids + elem_match.merge!(named_groups: named_groups) if named_groups criteria = where(:group_memberships.elem_match => elem_match) criteria.selector.delete("group_ids") diff --git a/lib/groupify/adapter/mongoid/model.rb b/lib/groupify/adapter/mongoid/model.rb index 7621fb4..be4ead4 100644 --- a/lib/groupify/adapter/mongoid/model.rb +++ b/lib/groupify/adapter/mongoid/model.rb @@ -15,18 +15,52 @@ def groupify(type, opts = {}) def acts_as_group(opts = {}) include Groupify::Mongoid::Group - if (member_klass = opts.delete :default_members) - self.default_member_class = member_klass.to_s.classify.constantize + # Get defaults from parent class for STI + self.default_member_class_name = Groupify.superclass_fetch(self, :default_member_class_name, Groupify.member_class_name) + self.default_members_association_name = Groupify.superclass_fetch(self, :default_members_association_name, Groupify.members_association_name) + + if (member_association_names = opts.delete :members) + has_members(member_association_names) + end + + if (default_members = opts.delete :default_members) + self.default_member_class_name = default_members.to_s.classify + # Only use as the association name if none specified (backwards-compatibility) + self.default_members_association_name ||= default_members end - if (member_klasses = opts.delete :members) - has_members(member_klasses) + if default_members_association_name + has_member(default_members_association_name, + class_name: default_member_class_name + ) end end def acts_as_group_member(opts = {}) - @group_class_name = opts[:group_class_name] || Groupify.group_class_name include Groupify::Mongoid::GroupMember + + # Get defaults from parent class for STI + self.default_group_class_name = Groupify.superclass_fetch(self, :default_group_class_name, Groupify.group_class_name) + self.default_groups_association_name = Groupify.superclass_fetch(self, :default_groups_association_name, Groupify.groups_association_name) + + if (group_association_names = opts.delete :groups) + has_groups(group_association_names) + end + + if (default_groups = opts.delete :default_groups) + self.default_group_class_name = default_groups.to_s.classify + self.default_groups_association_name ||= default_groups + end + + # Deprecated: for backwards-compatibility + if (group_class_name = opts.delete :group_class_name) + self.default_group_class_name = group_class_name + end + + if default_groups_association_name + has_group default_groups_association_name, + class_name: default_group_class_name + end end def acts_as_named_group_member(opts = {}) diff --git a/lib/groupify/adapter/mongoid/named_group_collection.rb b/lib/groupify/adapter/mongoid/named_group_collection.rb index b0bb5fe..599c777 100644 --- a/lib/groupify/adapter/mongoid/named_group_collection.rb +++ b/lib/groupify/adapter/mongoid/named_group_collection.rb @@ -4,22 +4,19 @@ module Mongoid module NamedGroupCollection # Criteria to filter by membership type def as(membership_type) - return self unless membership_type + return self unless membership_type.present? membership = @member.group_memberships.as(membership_type).first - if membership - membership.named_groups - else - self.class.new - end + + membership ? membership.named_groups : self.class.new end - def <<(named_group, opts={}) + def <<(named_group, opts = {}) named_group = named_group.to_sym super(named_group) uniq! - if @member && opts[:as] + if @member && opts[:as].present? membership = @member.group_memberships.find_or_initialize_by(as: opts[:as]) membership.named_groups << named_group membership.save! @@ -28,45 +25,39 @@ def <<(named_group, opts={}) self end - def merge(*args) - opts = args.extract_options! - named_groups = args.flatten + def merge(*named_groups) + opts = named_groups.extract_options! - named_groups.each do |named_group| + named_groups.flatten.each do |named_group| add(named_group, opts) end end - def delete(*args) - opts = args.extract_options! - named_groups = args.flatten + def delete(*named_groups) + membership_type = named_groups.extract_options![:as] + named_groups.flatten! if @member - if opts[:as] - membership = @member.group_memberships.as(opts[:as]).first - if membership - if ::Mongoid::VERSION > "4" - membership.pull_all(named_groups: named_groups) - else - membership.pull_all(:named_groups, named_groups) - end - end - - return + if membership_type.present? + skip_default = true + memberships = [@member.group_memberships.as(membership_type).first] else memberships = @member.group_memberships.where(:named_groups.in => named_groups) - memberships.each do |membership| - if ::Mongoid::VERSION > "4" - membership.pull_all(named_groups: named_groups) - else - membership.pull_all(:named_groups, named_groups) - end + end + + memberships.each do |membership| + if ::Mongoid::VERSION > "4" + membership.pull_all(named_groups: named_groups) + else + membership.pull_all(:named_groups, named_groups) end end end - named_groups.each do |named_group| - super(named_group) + unless skip_default + named_groups.each do |named_group| + super(named_group) + end end end diff --git a/lib/groupify/adapter/mongoid/named_group_member.rb b/lib/groupify/adapter/mongoid/named_group_member.rb index 615f2d5..6033d85 100644 --- a/lib/groupify/adapter/mongoid/named_group_member.rb +++ b/lib/groupify/adapter/mongoid/named_group_member.rb @@ -24,65 +24,50 @@ module NamedGroupMember end end - def in_named_group?(named_group, opts={}) + def in_named_group?(named_group, opts = {}) named_groups.as(opts[:as]).include?(named_group) end - def in_any_named_group?(*args) - opts = args.extract_options! - group_names = args.flatten - - group_names.each do |named_group| - return true if in_named_group?(named_group) - end - - return false + def in_any_named_group?(*group_names) + opts = group_names.extract_options! + group_names.flatten.any?{ |named_group| in_named_group?(named_group, opts) } end - def in_all_named_groups?(*args) - opts = args.extract_options! - named_groups = args.flatten.to_set - - named_groups.subset? self.named_groups.as(opts[:as]).to_set + def in_all_named_groups?(*named_groups) + membership_type = named_groups.extract_options![:as] + named_groups.flatten.to_set.subset? self.named_groups.as(membership_type).to_set end - def in_only_named_groups?(*args) - opts = args.extract_options! - named_groups = args.flatten.to_set - named_groups == self.named_groups.as(opts[:as]).to_set + def in_only_named_groups?(*named_groups) + membership_type = named_groups.extract_options![:as] + named_groups.flatten.to_set == self.named_groups.as(membership_type).to_set end - def shares_any_named_group?(other, opts={}) + def shares_any_named_group?(other, opts = {}) in_any_named_group?(other.named_groups, opts) end module ClassMethods - def in_named_group(named_group, opts={}) + def in_named_group(named_group, opts = {}) in_any_named_group(named_group, opts) end def in_any_named_group(*named_groups) named_groups.flatten! - return none unless named_groups.present? - - self.in(named_groups: named_groups.flatten) + named_groups.present? ? self.in(named_groups: named_groups) : none end def in_all_named_groups(*named_groups) named_groups.flatten! - return none unless named_groups.present? - - where(:named_groups.all => named_groups.flatten) + named_groups.present? ? where(:named_groups.all => named_groups) : none end def in_only_named_groups(*named_groups) named_groups.flatten! - return none unless named_groups.present? - - where(named_groups: named_groups.flatten) + named_groups.present? ? where(named_groups: named_groups) : none end - def shares_any_named_group(other, opts={}) + def shares_any_named_group(other, opts = {}) in_any_named_group(other.named_groups, opts) end end diff --git a/spec/active_record/ambiguous.rb b/spec/active_record/ambiguous.rb new file mode 100644 index 0000000..8d193a0 --- /dev/null +++ b/spec/active_record/ambiguous.rb @@ -0,0 +1,6 @@ +class Ambiguous < ActiveRecord::Base + self.table_name = 'groups' + + groupify :group, member_class_name: 'Ambiguous' + groupify :group_member, group_class_name: 'Ambiguous' +end diff --git a/spec/active_record/classroom.rb b/spec/active_record/classroom.rb new file mode 100644 index 0000000..7d2a425 --- /dev/null +++ b/spec/active_record/classroom.rb @@ -0,0 +1,4 @@ +class Classroom < ActiveRecord::Base + groupify :group + groupify :group_member +end diff --git a/spec/active_record/custom_group.rb b/spec/active_record/custom_group.rb new file mode 100644 index 0000000..96bb45a --- /dev/null +++ b/spec/active_record/custom_group.rb @@ -0,0 +1,4 @@ +class CustomGroup < ActiveRecord::Base + groupify :group, members: [:custom_users] + groupify :group_member +end diff --git a/spec/active_record/custom_group_membership.rb b/spec/active_record/custom_group_membership.rb new file mode 100644 index 0000000..ce78b5d --- /dev/null +++ b/spec/active_record/custom_group_membership.rb @@ -0,0 +1,3 @@ +class CustomGroupMembership < ActiveRecord::Base + groupify :group_membership +end diff --git a/spec/active_record/custom_user.rb b/spec/active_record/custom_user.rb new file mode 100644 index 0000000..fe77740 --- /dev/null +++ b/spec/active_record/custom_user.rb @@ -0,0 +1,4 @@ +class CustomUser < ActiveRecord::Base + groupify :group_member + groupify :named_group_member +end diff --git a/spec/active_record/enrollment.rb b/spec/active_record/enrollment.rb new file mode 100644 index 0000000..a4afcc2 --- /dev/null +++ b/spec/active_record/enrollment.rb @@ -0,0 +1,5 @@ +class Enrollment < ActiveRecord::Base + belongs_to :parent, inverse_of: :enrollments + belongs_to :student, inverse_of: :enrollments, autosave: true + belongs_to :university, inverse_of: :enrollments +end diff --git a/spec/active_record/group.rb b/spec/active_record/group.rb new file mode 100644 index 0000000..481f58f --- /dev/null +++ b/spec/active_record/group.rb @@ -0,0 +1,4 @@ +class Group < ActiveRecord::Base + groupify :group, members: [:users, :widgets, "namespaced/members"], default_members: :users + groupify :group_member +end diff --git a/spec/active_record/group_membership.rb b/spec/active_record/group_membership.rb new file mode 100644 index 0000000..600076b --- /dev/null +++ b/spec/active_record/group_membership.rb @@ -0,0 +1,3 @@ +class GroupMembership < ActiveRecord::Base + groupify :group_membership +end diff --git a/spec/active_record/manager.rb b/spec/active_record/manager.rb new file mode 100644 index 0000000..cd7f582 --- /dev/null +++ b/spec/active_record/manager.rb @@ -0,0 +1,4 @@ +require_relative 'user' + +class Manager < User +end diff --git a/spec/active_record/namespaced.rb b/spec/active_record/namespaced.rb new file mode 100644 index 0000000..d0a2ec3 --- /dev/null +++ b/spec/active_record/namespaced.rb @@ -0,0 +1,5 @@ +module Namespaced + class Member < ActiveRecord::Base + groupify :group_member + end +end diff --git a/spec/active_record/namespaced/member.rb b/spec/active_record/namespaced/member.rb new file mode 100644 index 0000000..d0a2ec3 --- /dev/null +++ b/spec/active_record/namespaced/member.rb @@ -0,0 +1,5 @@ +module Namespaced + class Member < ActiveRecord::Base + groupify :group_member + end +end diff --git a/spec/active_record/organization.rb b/spec/active_record/organization.rb new file mode 100644 index 0000000..3f4e642 --- /dev/null +++ b/spec/active_record/organization.rb @@ -0,0 +1,7 @@ +require_relative 'group' + +class Organization < Group + groupify :group_member + + has_members :managers, :organizations +end diff --git a/spec/active_record/parent.rb b/spec/active_record/parent.rb new file mode 100644 index 0000000..171c2ca --- /dev/null +++ b/spec/active_record/parent.rb @@ -0,0 +1,8 @@ +class Parent < ActiveRecord::Base + groupify :group_member + groupify :named_group_member + has_group :personas + + has_many :enrollments, inverse_of: :some_user + has_many :enrolled_students, ->{ distinct }, through: :enrollments, source: :student +end diff --git a/spec/active_record/project.rb b/spec/active_record/project.rb new file mode 100644 index 0000000..b4e922f --- /dev/null +++ b/spec/active_record/project.rb @@ -0,0 +1,3 @@ +class Project < ActiveRecord::Base + groupify :named_group_member +end diff --git a/spec/active_record/student.rb b/spec/active_record/student.rb new file mode 100644 index 0000000..da04d94 --- /dev/null +++ b/spec/active_record/student.rb @@ -0,0 +1,7 @@ +class Student < ActiveRecord::Base + groupify :group + groupify :group_member + has_group :universities + + has_many :enrollments, inverse_of: :student +end diff --git a/spec/active_record/university.rb b/spec/active_record/university.rb new file mode 100644 index 0000000..74e8ca8 --- /dev/null +++ b/spec/active_record/university.rb @@ -0,0 +1,5 @@ +class University < Group + has_member :students + + has_many :enrollments, inverse_of: :university, autosave: true +end diff --git a/spec/active_record/user.rb b/spec/active_record/user.rb new file mode 100644 index 0000000..164f618 --- /dev/null +++ b/spec/active_record/user.rb @@ -0,0 +1,7 @@ +class User < ActiveRecord::Base + groupify :group_member + groupify :named_group_member + + has_group :organizations, class_name: "Organization" + has_group :classrooms, class_name: "Classroom" +end diff --git a/spec/active_record/widget.rb b/spec/active_record/widget.rb new file mode 100644 index 0000000..81d4b97 --- /dev/null +++ b/spec/active_record/widget.rb @@ -0,0 +1,3 @@ +class Widget < ActiveRecord::Base + groupify :group_member +end diff --git a/spec/active_record_spec.rb b/spec/active_record_spec.rb index e5005d9..ad7a7d0 100644 --- a/spec/active_record_spec.rb +++ b/spec/active_record_spec.rb @@ -23,45 +23,24 @@ require 'groupify/adapter/active_record' -class User < ActiveRecord::Base - groupify :group_member - groupify :named_group_member +Groupify.configure do |config| + config.configure_legacy_defaults! end -class Manager < User -end - -class Widget < ActiveRecord::Base - groupify :group_member -end - -module Namespaced - class Member < ActiveRecord::Base - groupify :group_member - end -end - -class Project < ActiveRecord::Base - groupify :named_group_member -end - -class Group < ActiveRecord::Base - groupify :group, members: [:users, :widgets, "namespaced/members"], default_members: :users -end - -class Organization < Group - groupify :group_member - - has_members :managers, :organizations -end - -class GroupMembership < ActiveRecord::Base - groupify :group_membership -end - -class Classroom < ActiveRecord::Base - groupify :group -end +require_relative './active_record/ambiguous' +require_relative './active_record/user' +require_relative './active_record/manager' +require_relative './active_record/widget' +require_relative './active_record/namespaced/member' +require_relative './active_record/project' +require_relative './active_record/group' +require_relative './active_record/organization' +require_relative './active_record/group_membership' +require_relative './active_record/classroom' +require_relative './active_record/parent' +require_relative './active_record/student' +require_relative './active_record/university' +require_relative './active_record/enrollment' describe Group do it { should respond_to :members} @@ -76,6 +55,224 @@ class Classroom < ActiveRecord::Base it { should respond_to :shares_any_group?} end +describe Groupify::ActiveRecord do + let(:user) { User.create! } + let(:group) { Group.create!(id: 10) } + let(:classroom) { Classroom.create!(id: 10) } + let(:organization) { Organization.create!(id: 11) } + + describe "polymorphic groups" do + context "memberships" do + it "auto-saves new group record when adding a member" do + group = Group.new + group.add user + + expect(group.persisted?).to eq true + expect(user.persisted?).to eq true + expect(group.users.size).to eq 1 + expect(user.groups.size).to eq 1 + expect(group.users).to include(user) + expect(user.groups).to include(group) + + user.reload + + expect(user.groups.first).to eq group + + group.reload + + expect(group.users.first).to eq user + end + + it "auto-saves new member record when adding to a group" do + group = Group.create! + user = User.new + group.add user + + expect(group.persisted?).to eq true + expect(user.persisted?).to eq true + expect(group.users.size).to eq 1 + expect(user.groups.size).to eq 1 + expect(group.users).to include(user) + expect(user.groups).to include(group) + + user.reload + + expect(user.groups.first).to eq group + + group.reload + + expect(group.users.first).to eq user + end + + it "finds multiple records for different models with same ID" do + group.add user + classroom.add user + organization.add user + + expect(group.id).to eq(10) + expect(classroom.id).to eq(10) + expect(organization.id).to eq(11) + + membership_groups = user.group_memberships_as_member.map(&:group) + + expect(membership_groups).to include(group, classroom, organization) + expect(GroupMembership.for_groups([group, classroom]).count).to eq(2) + expect(GroupMembership.for_groups([group, classroom]).map(&:group)).to include(group, classroom) + expect(GroupMembership.for_groups([group, classroom]).distinct.count).to eq(2) + expect(GroupMembership.for_groups([group, classroom, organization]).count).to eq(3) + expect(GroupMembership.for_groups([group, classroom, organization]).map(&:group)).to include(group, classroom, organization) + expect(GroupMembership.for_groups([group, classroom]).map(&:member).uniq.size).to eq(1) + expect(GroupMembership.for_groups([group, classroom]).map(&:member).uniq.first).to eq(user) + end + + it "infers class name for association based on association name" do + organization = Organization.create! + organization1 = Organization.create! + organization2 = Organization.create! + group = Group.create! + manager = Manager.create! + + organization.add organization1 + organization.add organization2 + organization.add group + organization.add manager + + expect(organization.organizations.count).to eq(2) + expect(organization.organizations).to include(organization1, organization2) + end + + it "member has groups in has_many through associations after adding member to groups" do + + expect(user.groups.size).to eq(0) + + group.add user + organization.add user + + expect(user.groups.size).to eq(2) + end + + it "member doesn't have groups in has_many through associations after deleting member from group" do + group.add user + + expect(user.groups.size).to eq(1) + + user.groups.delete group + + expect(user.groups.size).to eq(0) + end + + it "doesn't select duplicate groups" do + group.add user, as: 'manager' + group.add user, as: 'user' + classroom.add user + + expect(user.polymorphic_groups.count).to eq(2) + expect(user.polymorphic_groups.to_a.size).to eq(2) + expect(user.groups.count).to eq(1) + end + + it "adds based on membership_type" do + group.add user + group.add user, as: 'manager' + organization.add user + organization.add user, as: 'owner' + + expect(user.polymorphic_groups.count).to eq(2) + expect(user.group_memberships_as_member.count).to eq(4) + end + + it "properly checks group inclusion with complex relationships (Rails 4.2 bug)" do + parent = Parent.create! + student1 = Student.create!(id: 1) + student2 = Student.create!(id: 2) + + student1.add parent + student2.add parent + + university1 = University.new(id: 11) + university1.enrollments.build(parent: parent, student: student1) + university1.save! + + university1.add student1 + + university2 = University.new(id: 22) + university2.enrollments.build(parent: parent, student: student2) + university2.save! + + university2.add student2 + + # Initially, things are as expected + + expect(student1.in_group?(university1)).to eq(true) + expect(student1.in_group?(university2)).to eq(false) + expect(student2.in_group?(university1)).to eq(false) + expect(student2.in_group?(university2)).to eq(true) + + enrolled_students = parent.enrolled_students.to_a.sort_by(&:id) + + expect(enrolled_students[0].id).to eq(1) + expect(enrolled_students[0].in_group?(university1)).to eq(true) + expect(enrolled_students[0].in_group?(university2)).to eq(false) + + expect(enrolled_students[1].id).to eq(2) + expect(enrolled_students[1].in_group?(university1)).to eq(false) + expect(enrolled_students[1].in_group?(university2)).to eq(true) + + # After getting records fresh from the database, a bug in Rails 4 + # returns the same `exists?` result (inside `in_group?`) for each record. + # + # This seems to be a result of some internal cache that retrieves the + # wrong internal records or values when merging or querying. + + parent = Parent.first + university2 = University.find(22) + + student2 = Student.find(2) + + results = parent.enrolled_students.map{ |s| [s.id, s.in_group?(university2)]} + + expect(results.sort_by(&:first)).to eq([[1, false], [2, true]]) + end + + it "properly joins on group memberships table when chaining" do + parent1 = Parent.create! + student1 = Student.create! + + student1.add parent1 + + parent2 = Parent.create! + student2 = Student.create! + + student2.add parent2 + + university1 = University.new + university1.enrollments.build(parent: parent1, student: student1) + university1.save! + + university1.add student1, as: :athlete + + university2 = University.new + university2.enrollments.build(parent: parent1, student: student1) + university2.enrollments.build(parent: parent2, student: student2) + university2.save! + + university2.add student1 + university2.add student2, as: :athlete + + expect(University.with_member(parent1.enrolled_students)).to include(university1, university2) + + expect(University.with_members(parent1.enrolled_students).as(:athlete)).to include(university1) + expect(University.with_members(parent1.enrolled_students).as(:athlete)).to_not include(university2) + end + + xit "doesn't allow merging associations that don't go through group memberships" do + expect{ University.with_member(Parent.new.enrolled_students) }.to raise_error(Groupify::ActiveRecord::InvalidAssociationError) + expect{ University.with_memberships_for_group(criteria: Parent.new.enrolled_students) }.to raise_error(Groupify::ActiveRecord::InvalidAssociationError) + end + end + end +end + describe Groupify::ActiveRecord do let(:user) { User.create! } let(:group) { Group.create! } @@ -90,18 +287,9 @@ class Classroom < ActiveRecord::Base config.group_membership_class_name = 'CustomGroupMembership' end - class CustomGroupMembership < ActiveRecord::Base - groupify :group_membership - end - - class CustomUser < ActiveRecord::Base - groupify :group_member - groupify :named_group_member - end - - class CustomGroup < ActiveRecord::Base - groupify :group, members: [:custom_users] - end + require_relative './active_record/custom_group_membership' + require_relative './active_record/custom_user' + require_relative './active_record/custom_group' end after do @@ -151,15 +339,19 @@ class CustomGroup < ActiveRecord::Base context "member with custom group model" do before do + class CustomProject < ActiveRecord::Base + groupify :group + end + class ProjectMember < ActiveRecord::Base - groupify :group_member, group_class_name: 'Project' + groupify :group_member, group_class_name: 'CustomProject' end end it "overrides the default group name on a per-model basis" do member = ProjectMember.create! member.groups.create! - expect(member.groups.first).to be_a Project + expect(member.groups.first).to be_a CustomProject end end end @@ -209,7 +401,29 @@ class ProjectMember < ActiveRecord::Base expect(group.users).to include(*users) end - it "only allows members to be added to their configured group type" do + it "only adds group to member.groups once when added directly to association" do + user.groups << group + user.groups << group + + expect(user.groups.count).to eq(1) + + user.groups.reload + + expect(user.groups.count).to eq(1) + end + + it "only adds member to group.members once when added directly to association" do + group.members << user + group.members << user + + expect(group.members.count).to eq(1) + + group.members.reload + + expect(group.members.count).to eq(1) + end + + xit "only allows members to be added to their configured group type" do classroom = Classroom.create! expect { classroom.add(user) }.to raise_error(ActiveRecord::AssociationTypeMismatch) expect { user.groups << classroom }.to raise_error(ActiveRecord::AssociationTypeMismatch) @@ -221,6 +435,29 @@ class ProjectMember < ActiveRecord::Base parent_org.add(child_org) expect(parent_org.organizations).to include(child_org) end + + it "can have subclassed associations for groups of a specific kind" do + org = Organization.create! + + user.groups << group + user.groups << org + + expect(user.groups).to include(group) + expect(user.groups).to include(org) + + expect(user.organizations).to_not include(group) + expect(user.organizations).to include(org) + + expect(org.members).to include(user) + expect(group.members).to include(user) + + expect(user.organizations.count).to eq(1) + expect(user.organizations.first).to be_a(Organization) + + expect(user.groups.count).to eq(2) + expect(user.groups.first).to be_a(Organization) + expect(user.groups[1]).to be_a(Group) + end end it "lists which member classes can belong to this group" do @@ -250,6 +487,9 @@ class ProjectMember < ActiveRecord::Base group.users.delete(user) group.widgets.destroy(widget) + expect(user.groups).to_not include(group) + expect(widget.groups).to_not include(group) + expect(group.widgets).to_not include(widget) expect(group.users).to_not include(user) @@ -264,6 +504,9 @@ class ProjectMember < ActiveRecord::Base user.groups.delete(group) widget.groups.destroy(group) + expect(group.users).to_not include(user) + expect(group.widgets).to_not include(widget) + expect(group.widgets).to_not include(widget) expect(group.users).to_not include(user) @@ -291,6 +534,31 @@ class ProjectMember < ActiveRecord::Base end end + context "when designating a model as a group and member" do + it "finds members" do + member1 = Ambiguous.create!(name: "member1") + member2 = Ambiguous.create!(name: "member2") + group1 = Ambiguous.create!(name: "group1") + group2 = Ambiguous.create!(name: "group2") + + group1.add member1 + group2.add member2, as: 'member' + + expect(group1.members).to include(member1) + expect(group2.members).to include(member2) + + expect(group2.members.as(:member)).to include(member2) + expect(member2.groups.as(:member)).to include(group2) + + expect(Ambiguous.as(:member)).to include(member2) + expect(Ambiguous.as(:member)).to_not include(group2) + + expect(Ambiguous.with_member(member1)).to include(group1) + expect(Ambiguous.with_member(member1)).to_not include(member1) + expect(Ambiguous.with_member(member1)).to_not include(member2) + end + end + context 'when checking group membership' do it "members can check if they belong to any/all groups" do user.groups << group @@ -335,6 +603,31 @@ class ProjectMember < ActiveRecord::Base end end + context "when retrieving membership types" do + it "gets a list of membership types for a group" do + group.add user, as: :owner + group.add user, as: :admin + + expect(user.membership_types_for_group(group)).to include(nil, 'owner', 'admin') + end + + it "gets a list of membership types for a named group" do + project = Project.create! + + project.named_groups.add :workgroup, as: :owner + project.named_groups.add :workgroup, as: :admin + + expect(project.membership_types_for_named_group(:workgroup)).to include(nil, 'owner', 'admin') + end + + it "gets a list of membership types for a member" do + group.add user, as: :owner + group.add user, as: :admin + + expect(group.membership_types_for_member(user)).to include(nil, 'owner', 'admin') + end + end + context 'when merging groups' do let(:task) { Task.create! } let(:manager) { Manager.create! } @@ -430,6 +723,22 @@ class ProjectMember < ActiveRecord::Base expect(User.as(:manager)).to include(user) end + it "finds members by multiple membership types" do + organization = Organization.create! + classroom = Classroom.create! + + organization.add user, as: 'manager' + organization.add user, as: 'employee' + classroom.add user, as: 'teacher' + group.add user, as: 'manager' + + expect(User.as(:teacher, :manager)).to include(user) + expect(User.as(:teacher, :employee)).to include(user) + expect(user.polymorphic_groups.as(:manager, :employee)).to include(organization, group) + expect(user.polymorphic_groups.as(:manager, :employee)).to_not include(classroom) + expect(user.polymorphic_groups.as(:teacher, :manager)).to include(classroom, organization, group) + end + it "finds members by group with membership type" do group.add user, as: 'employee' @@ -578,6 +887,14 @@ class ProjectMember < ActiveRecord::Base expect(user.named_groups).to be_empty end + it "works when using only named groups and not groups" do + project = Project.create! + project.named_groups.add(:accounting) + expect(project.named_groups).to include(:accounting) + project.named_groups.delete_all + expect(project.named_groups).to be_empty + end + it "checks if a member belongs to one named group" do expect(user.in_named_group?(:admin)).to be true expect(User.in_named_group(:admin).first).to eql(user) @@ -692,6 +1009,11 @@ class ProjectMember < ActiveRecord::Base expect(user.named_groups.as(:developer)).to_not include(:team1) expect(user.named_groups.as(:employee)).to include(:team2) end + + it "finds all named group memberships for multiple membership types" do + expect(user.named_groups.as(:manager, :developer)).to include(:team3, :team1) + expect(user.named_groups.as(:manager, :developer)).to_not include(:team2) + end end end end diff --git a/spec/internal/db/schema.rb b/spec/internal/db/schema.rb index b648652..36a4708 100644 --- a/spec/internal/db/schema.rb +++ b/spec/internal/db/schema.rb @@ -24,6 +24,11 @@ t.string :name end + create_table :custom_projects do |t| + t.string :name + t.string :type + end + create_table :organizations do |t| t.string :name end @@ -56,4 +61,18 @@ create_table :project_members do |t| t.string :name end + + create_table :parents do |t| + t.string :name + end + + create_table :students do |t| + t.string :name + end + + create_table :enrollments do |t| + t.references :parent, index: true + t.references :student, index: true + t.references :university, index: true + end end diff --git a/spec/mongoid_spec.rb b/spec/mongoid_spec.rb index af34fba..349aa22 100644 --- a/spec/mongoid_spec.rb +++ b/spec/mongoid_spec.rb @@ -1,6 +1,6 @@ RSpec.configure do |config| config.order = "random" - + config.before(:suite) do DatabaseCleaner[:mongoid].strategy = :truncation end @@ -41,9 +41,13 @@ require 'groupify/adapter/mongoid' +Groupify.configure do |config| + config.configure_legacy_defaults! +end + class MongoidUser include Mongoid::Document - + groupify :group_member, group_class_name: 'MongoidGroup' groupify :named_group_member end @@ -58,7 +62,7 @@ class MongoidWidget class MongoidTask include Mongoid::Document - + groupify :group_member, group_class_name: 'MongoidGroup' end @@ -70,7 +74,7 @@ class MongoidIssue class MongoidGroup include Mongoid::Document - + groupify :group, members: [:mongoid_users, :mongoid_tasks, :mongoid_widgets, :mongoid_groups], default_members: :mongoid_users groupify :group_member, group_class_name: "MongoidGroup" @@ -104,7 +108,7 @@ class MongoidProject < MongoidGroup expect(MongoidGroup.new.members).to be_empty expect(group.members).to be_empty end - + context "when adding" do it "adds a group to a member" do user.groups << group @@ -112,7 +116,7 @@ class MongoidProject < MongoidGroup expect(group.members).to include(user) expect(group.users).to include(user) end - + it "adds a member to a group" do expect(user.groups).to be_empty group.add user @@ -144,16 +148,16 @@ class MongoidProject < MongoidGroup expect(MongoidProject.member_classes).to include(MongoidUser, MongoidTask, MongoidIssue) end - + it "finds members by group" do group.add user - + expect(MongoidUser.in_group(group).first).to eql(user) end it "finds the groups a member belongs to" do group.add user - + expect(MongoidGroup.with_member(user).first).to eq(group) end @@ -260,7 +264,7 @@ class MongoidProject < MongoidGroup destination.merge!(source) expect(source.destroyed?).to be true - + expect(destination.users.to_a).to include(user) expect(destination.managers.to_a).to include(manager) expect(destination.tasks.to_a).to include(task) @@ -315,14 +319,14 @@ class MongoidProject < MongoidGroup user.save! group4 = MongoidGroup.create! - + expect(user.groups).to include(group, group2, group3) expect(MongoidUser.in_group(group).first).to eql(user) expect(MongoidUser.in_group(group2).first).to eql(user) expect(user.in_group?(group)).to be true expect(user.in_group?(group4)).to be false - + expect(MongoidUser.in_any_group(group, group4).first).to eql(user) expect(MongoidUser.in_any_group(group4)).to be_empty expect(user.in_any_group?(group2, group4)).to be true @@ -346,7 +350,7 @@ class MongoidProject < MongoidGroup context "when using membership types with groups" do it 'adds groups to a member with a specific membership type' do group.add(user, as: :admin) - + expect(user.groups).to include(group) expect(group.members).to include(user) expect(group.users).to include(user) @@ -558,7 +562,7 @@ class MongoidProject < MongoidGroup it "checks if named groups are shared" do user2 = MongoidUser.create!(:named_groups => [:admin]) - + expect(user.shares_any_named_group?(user2)).to be true expect(MongoidUser.shares_any_named_group(user).to_a).to include(user2) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 20188d8..7a8fb52 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,8 +4,8 @@ require 'coveralls' SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new [ - SimpleCov::Formatter::HTMLFormatter, - Coveralls::SimpleCov::Formatter + SimpleCov::Formatter::HTMLFormatter, + Coveralls::SimpleCov::Formatter ] SimpleCov.start