genealogy 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,55 @@
1
1
  module Genealogy
2
- PARENT2LINEAGE = { :father => :paternal, :mother => :maternal }
3
- LINEAGE2PARENT = PARENT2LINEAGE.invert
4
- PARENT2SEX = { :father => :male, :mother => :female }
5
- SEX2PARENT = PARENT2SEX.invert
6
- OPPOSITESEX = {:male => :female, :female => :male}
2
+ module Constants
3
+ # deafault options for has_parent method
4
+ DEFAULTS = {
5
+ column_names: {
6
+ sex: 'sex',
7
+ father_id: 'father_id',
8
+ mother_id: 'mother_id',
9
+ current_spouse_id: 'current_spouse_id',
10
+ birth_date: 'birth_date',
11
+ death_date: 'death_date'
12
+ },
13
+ perform_validation: true,
14
+ ineligibility: :pedigree,
15
+ current_spouse: false,
16
+ sex_values: ['M','F'],
17
+ limit_ages: {
18
+ min_male_procreation_age: 12,
19
+ max_male_procreation_age: 75,
20
+ min_female_procreation_age: 9,
21
+ max_female_procreation_age: 50,
22
+ max_male_life_expectancy: 110,
23
+ max_female_life_expectancy: 110
24
+ }
25
+ }
7
26
 
8
- AKA = {
9
- :father => "F",
10
- :mother => "M",
11
- :paternal_grandfather => "PGF",
12
- :paternal_grandmother => "PGM",
13
- :maternal_grandfather => "MGF",
14
- :maternal_grandmother => "MGM",
15
- :children => "C",
16
- :siblings => "S",
17
- :half_siblings => "HS",
18
- :paternal_half_siblings => "PHS",
19
- :maternal_half_siblings => "MHS",
20
- :grandchildren => "GC",
21
- :uncles_and_aunts => "U&A",
22
- :nieces_and_nephews => "N&N"
23
- }
27
+ # ineligibility levels
28
+ PEDIGREE = 1
29
+ PEDIGREE_AND_DATES = 2
30
+ OFF = 0
31
+
32
+ PARENT2LINEAGE = ActiveSupport::HashWithIndifferentAccess.new({ father: :paternal, mother: :maternal })
33
+ LINEAGE2PARENT = ActiveSupport::HashWithIndifferentAccess.new({ paternal: :father, maternal: :mother })
34
+ PARENT2SEX = ActiveSupport::HashWithIndifferentAccess.new({ father: :male, mother: :female })
35
+ SEX2PARENT = ActiveSupport::HashWithIndifferentAccess.new({ male: :father, female: :mother })
36
+ OPPOSITESEX = ActiveSupport::HashWithIndifferentAccess.new({male: :female, female: :male})
37
+
38
+ AKA = ActiveSupport::HashWithIndifferentAccess.new({
39
+ father: "F",
40
+ mother: "M",
41
+ paternal_grandfather: "PGF",
42
+ paternal_grandmother: "PGM",
43
+ maternal_grandfather: "MGF",
44
+ maternal_grandmother: "MGM",
45
+ children: "C",
46
+ siblings: "S",
47
+ half_siblings: "HS",
48
+ paternal_half_siblings: "PHS",
49
+ maternal_half_siblings: "MHS",
50
+ grandchildren: "GC",
51
+ uncles_and_aunts: "U&A",
52
+ nieces_and_nephews: "N&N"
53
+ })
54
+ end
24
55
  end
@@ -0,0 +1,66 @@
1
+ module Genealogy
2
+ # Module CurrentSpouseMethods provides methods to manage and query current spouse. It's included by the genealogy enabled AR model
3
+ module CurrentSpouseMethods
4
+ extend ActiveSupport::Concern
5
+
6
+ include Constants
7
+
8
+ # add current spouse updating receiver and argument individuals foreign_key in a transaction
9
+ # @param [Object] spouse
10
+ # @return [Boolean]
11
+ def add_current_spouse(spouse)
12
+
13
+ raise_unless_current_spouse_enabled
14
+ check_incompatible_relationship(:current_spouse,spouse)
15
+
16
+ if gclass.perform_validation_enabled
17
+ self.current_spouse = spouse
18
+ spouse.current_spouse = self
19
+ transaction do
20
+ spouse.save!
21
+ save!
22
+ end
23
+ else
24
+ transaction do
25
+ self.update_attribute(:current_spouse,spouse)
26
+ spouse.update_attribute(:current_spouse,self)
27
+ end
28
+ end
29
+
30
+ end
31
+
32
+ # remove current spouse resetting receiver and argument individuals foreign_key in a transaction
33
+ # @return [Boolean]
34
+ def remove_current_spouse
35
+ raise_unless_current_spouse_enabled
36
+ if gclass.perform_validation_enabled
37
+ ex_current_spouse = current_spouse
38
+ current_spouse.current_spouse = nil
39
+ self.current_spouse = nil
40
+ transaction do
41
+ ex_current_spouse.save!
42
+ save!
43
+ end
44
+ else
45
+ transaction do
46
+ current_spouse.update_attribute(:current_spouse,nil)
47
+ self.update_attribute(:current_spouse,nil)
48
+ end
49
+ end
50
+ end
51
+
52
+ # list of individual who cannot be current spouse
53
+ # @return [Array]
54
+ def ineligible_current_spouses
55
+ raise_unless_current_spouse_enabled
56
+ self.gclass.send(ssex.to_s.pluralize)
57
+ end
58
+
59
+ private
60
+
61
+ def raise_unless_current_spouse_enabled
62
+ raise FeatureNotEnabled, "Spouse tracking not enabled. Enable it with option 'current_spouse_enabled: true' for has_parents method}" unless self.class.current_spouse_enabled
63
+ end
64
+
65
+ end
66
+ end
@@ -1,12 +1,9 @@
1
1
  module Genealogy
2
- class WrongArgumentException < RuntimeError; end
3
- class WrongOptionException < RuntimeError; end
4
- class WrongOptionValueException < RuntimeError; end
5
- class LineageGapException < RuntimeError; end
6
- class IncompatibleObjectException < RuntimeError; end
7
- class WrongSexException < RuntimeError; end
8
- class IncompatibleRelationshipException < RuntimeError
9
- def initialize(msg = "Trying to create a relationship incopatible with the the existing ones")
2
+ class LineageGapException < StandardError; end
3
+ class FeatureNotEnabled < StandardError; end
4
+ class SexError < StandardError; end
5
+ class IncompatibleRelationshipException < StandardError
6
+ def initialize(msg = "Trying to create an incompatible relationship")
10
7
  super(msg)
11
8
  end
12
9
  end
@@ -1,82 +1,119 @@
1
1
  module Genealogy
2
2
 
3
- def check_options(options, admitted_keys)
3
+ extend ActiveSupport::Concern
4
4
 
5
- raise WrongArgumentException, "first argument must be in a hash." unless options.is_a? Hash
6
- raise WrongArgumentException, "seconf argument must be in an array." unless admitted_keys.is_a? Array
5
+ module ClassMethods
6
+
7
+ include Genealogy::Constants
7
8
 
8
- options.each do |key, value|
9
- unless admitted_keys.include? key
10
- raise WrongOptionException.new("Unknown option: #{key.inspect} => #{value.inspect}.")
9
+ # gives to ActiveRecord model geneaogy capabilities. Modules UtilMethods QueryMethods IneligibleMethods AlterMethods and SpouseMethods are included
10
+ # @param [Hash] options
11
+ # @option options [Boolean] current_spouse (false) specifies whether to track or not individual's current spouse
12
+ # @option options [Boolean] perform_validation (true) specifies whether to perform validation or not while altering pedigree that is before updating relatives external keys
13
+ # @option options [Boolean, Symbol] ineligibility (:pedigree) specifies ineligibility setting. If `false` ineligibility checks will be disabled and you can assign, as a relative, any individuals you want.
14
+ # This can be dangerous because you can build nosense loop (in terms of pedigree). If pass one of symbols `:pedigree` or `:pedigree_and_dates` ineligibility checks will be enabled.
15
+ # More specifically with `:pedigree` (or `true`) checks will be based on pedigree topography, i.e., ineligible children will include ancestors. With `:pedigree_and_dates` check will also be based on
16
+ # procreation ages (min and max, male and female) and life expectancy (male and female), i.e. an individual born 200 years before is an ineligible mother
17
+ # @option options [Hash] limit_ages (min_male_procreation_age:12, max_male_procreation_age:75, min_female_procreation_age:9, max_female_procreation_age:50, max_male_life_expectancy:110, max_female_life_expectancy:110)
18
+ # specifies one or more limit ages different than defaults
19
+ # @option options [Hash] column_names (sex:'sex', father_id:'father_id', mother_id:'mother_id', current_spouse_id:'current_spouse_id', birth_date:'birth_date', death_date:'death_date') specifies column names to map database individual table
20
+ # @option options [Array] sex_values (['M','F']) specifies values used in database sex column
21
+ # @return [void]
22
+ def has_parents options = {}
23
+
24
+ check_options(options)
25
+
26
+ # keep track of the original extend class to prevent wrong scopes in query method in case of STI
27
+ class_attribute :gclass, instance_writer: false
28
+ self.gclass = self
29
+
30
+ class_attribute :ineligibility_level, instance_accessor: false
31
+ self.ineligibility_level = case options[:ineligibility]
32
+ when :pedigree
33
+ PEDIGREE
34
+ when true
35
+ PEDIGREE
36
+ when :pedigree_and_dates
37
+ PEDIGREE_AND_DATES
38
+ when false
39
+ OFF
40
+ when nil
41
+ PEDIGREE
42
+ else
43
+ raise ArgumentError, "ineligibility option must be one among :pedigree, :pedigree_and_dates or false"
11
44
  end
12
- if block_given?
13
- yield key, value
45
+
46
+ ## limit_ages
47
+ if ineligibility_level >= PEDIGREE_AND_DATES
48
+ DEFAULTS[:limit_ages].each do |age,v|
49
+ class_attribute age, instance_accessor: false
50
+ self.send("#{age}=", options[:limit_ages].try(:[],age) || v)
51
+ end
14
52
  end
15
- end
16
53
 
17
- end
54
+ [:current_spouse, :perform_validation].each do |opt|
55
+ ca = "#{opt}_enabled"
56
+ class_attribute ca, instance_accessor: false
57
+ self.send "#{ca}=", options.key?(opt) ? options[opt] : DEFAULTS[opt]
58
+ end
18
59
 
19
- def has_parents options = {}
20
60
 
21
- admitted_keys = [:sex_column, :sex_values, :father_column, :mother_column, :current_spouse_column, :current_spouse, :birth_date_column, :death_date_column, :perform_validation]
22
- check_options(options, admitted_keys) do |key, value|
23
- if key == :sex_values
24
- raise WrongOptionException, ":sex_values option must be an array of length 2: first for male sex symbol an last for female" unless value.is_a?(Array) and value.size == 2
61
+ # column names class attributes
62
+ DEFAULTS[:column_names].merge(options[:column_names]).each do |k,v|
63
+ class_attribute_name = "#{k}_column"
64
+ class_attribute class_attribute_name, instance_accessor: false
65
+ self.send("#{class_attribute_name}=", v)
66
+ alias_attribute k, v unless k == v.to_sym
25
67
  end
26
- end
27
68
 
28
- class_attribute :genealogy_enabled, :current_spouse_enabled, :genealogy_class, :perform_validation
29
- self.genealogy_enabled = true
30
- self.current_spouse_enabled = options[:current_spouse].try(:==,true) || false # default false
31
- self.genealogy_class = self # keep track of the original extend class to prevent wrong scopes in query method in case of STI
32
- self.perform_validation = options[:perform_validation].try(:==,false) ? false : true # default true
33
-
34
- tracked_relatives = [:father, :mother]
35
- tracked_relatives << :current_spouse if current_spouse_enabled
36
-
37
- ## sex
38
- # class attributes
39
- class_attribute :sex_column, :sex_values, :sex_male_value, :sex_female_value, :birth_date_column, :death_date_column
40
- self.sex_column = options[:sex_column] || 'sex'
41
- self.sex_values = options[:sex_values] || ['M','F']
42
- self.sex_male_value = self.sex_values.first
43
- self.sex_female_value = self.sex_values.last
44
- self.birth_date_column = options[:birth_date_column] || 'birth_date'
45
- self.death_date_column = options[:death_date_column] || 'death_date'
46
- # instance attribute
47
- alias_attribute :sex, sex_column if self.sex_column != 'sex'
48
- # validation
49
- validates_presence_of sex_column
50
- validates_format_of sex_column, :with => /[#{sex_values.join}]/
51
-
52
- tracked_relatives.each do |key|
53
- # class attribute where is stored the correspondig foreign_key column name
54
- class_attribute_name = "#{key}_column"
55
- foreign_key = "#{key}_id"
56
- class_attribute class_attribute_name
57
- self.send("#{class_attribute_name}=", options[class_attribute_name.to_sym] || foreign_key)
58
- # self join associations
59
- belongs_to key, class_name: self, foreign_key: foreign_key
60
- end
69
+ ## sex
70
+ class_attribute :sex_values, :sex_male_value, :sex_female_value, instance_accessor: false
71
+ self.sex_values = options[:sex_values] || DEFAULTS[:sex_values]
72
+ self.sex_male_value = self.sex_values.first
73
+ self.sex_female_value = self.sex_values.last
74
+
75
+ # validation
76
+ validates_presence_of :sex
77
+ validates_format_of :sex, with: /[#{sex_values.join}]/
61
78
 
62
- has_many :children_as_father, :class_name => self, :foreign_key => self.father_column, :dependent => :nullify, :extend => FatherAssociationExtension
63
- has_many :children_as_mother, :class_name => self, :foreign_key => self.mother_column, :dependent => :nullify, :extend => MotherAssociationExtension
79
+ tracked_relatives = [:father, :mother]
80
+ tracked_relatives << :current_spouse if current_spouse_enabled
81
+ tracked_relatives.each do |k|
82
+ belongs_to k, class_name: self, foreign_key: self.send("#{k}_id_column")
83
+ end
64
84
 
65
- # Include instance methods and class methods
66
- include Genealogy::QueryMethods
67
- include Genealogy::AlterMethods
68
- include Genealogy::SpouseMethods if current_spouse_enabled
85
+ has_many :children_as_father, class_name: self, foreign_key: self.father_id_column, dependent: :nullify
86
+ has_many :children_as_mother, class_name: self, foreign_key: self.mother_id_column, dependent: :nullify
69
87
 
70
- end
88
+ include Genealogy::UtilMethods
89
+ include Genealogy::QueryMethods
90
+ include Genealogy::IneligibleMethods
91
+ include Genealogy::AlterMethods
92
+ include Genealogy::CurrentSpouseMethods
71
93
 
72
- module MotherAssociationExtension
73
- def with(father_id)
74
- where(father_id: father_id)
75
94
  end
76
- end
77
- module FatherAssociationExtension
78
- def with(mother_id)
79
- where(mother_id: mother_id)
95
+
96
+ private
97
+
98
+ def check_options(options)
99
+
100
+ raise ArgumentError, "Hash expected, #{options.class} given." unless options.is_a? Hash
101
+
102
+ # column names
103
+ options[:column_names] ||= {}
104
+ raise ArgumentError, "Hash expected for :column_names option, #{options[:column_names].class} given." unless options[:column_names].is_a? Hash
105
+
106
+ # sex
107
+ if array = options[:sex_values]
108
+ raise ArgumentError, ":sex_values option must be an array of length 2: [:male_value, :female_value]" unless array.is_a?(Array) and array.size == 2
109
+ end
110
+
111
+ # booleans
112
+ options.slice(:perform_validation, :current_spouse).each do |k,v|
113
+ raise ArgumentError, "Boolean expected for #{k} option, #{v.class} given." unless !!v == v
114
+ end
80
115
  end
116
+
81
117
  end
118
+
82
119
  end
@@ -0,0 +1,116 @@
1
+ module Genealogy
2
+ # Module IneligibleMethods provides methods to run genealogy queries to retrive groups of individuals who cannot be relatives according to provided role.
3
+ # It's included by the genealogy enabled AR model
4
+ module IneligibleMethods
5
+ extend ActiveSupport::Concern
6
+
7
+ include Constants
8
+
9
+ # @!macro [attach] generate
10
+ # @method ineligible_$1s
11
+ # list of individual who cannot be $1
12
+ # @return [Array,NilClass] Return nil if $1 already assigned
13
+ def self.generate_method_ineligibles_parent(parent)
14
+ define_method "ineligible_#{parent}s" do
15
+ unless self.send(parent)
16
+ ineligibles = []
17
+ ineligibles |= descendants | [self] | gclass.send("#{OPPOSITESEX[PARENT2SEX[parent]]}s") if gclass.ineligibility_level >= PEDIGREE
18
+ if gclass.ineligibility_level >= PEDIGREE_AND_DATES and life_range
19
+ ineligibles |= (gclass.all - ineligibles).find_all do |indiv|
20
+ if indiv.life_range
21
+ if birth
22
+ !indiv.can_procreate_on?(birth)
23
+ else
24
+ !indiv.can_procreate_during?(life_range)
25
+ end
26
+ else
27
+ false
28
+ end
29
+ end
30
+ end
31
+ ineligibles
32
+ end
33
+ end
34
+ end
35
+ generate_method_ineligibles_parent(:father)
36
+ generate_method_ineligibles_parent(:mother)
37
+
38
+
39
+ # @!macro [attach] generate
40
+ # @method ineligible_$1_grand$2(grandparent)
41
+ # list of individual who cannot be $1 grand$2
42
+ # @return [Array,NilClass] Return nil if $$1 grand$2 already assigned
43
+ def self.generate_method_ineligible_grandparent(lineage,grandparent)
44
+ relationship = "#{lineage}_grand#{grandparent}"
45
+ define_method "ineligible_#{relationship}s" do
46
+ unless send(relationship)
47
+ parent = send(LINEAGE2PARENT[lineage])
48
+ ineligibles = []
49
+ ineligibles |= [parent].compact | descendants | siblings | send("#{lineage}_half_siblings") | [self] | gclass.send("#{OPPOSITESEX[PARENT2SEX[grandparent]]}s") if gclass.ineligibility_level >= PEDIGREE
50
+ if gclass.ineligibility_level >= PEDIGREE_AND_DATES
51
+ ineligibles |= if parent
52
+ parent.send("ineligible_#{grandparent}s")
53
+ else
54
+ (gclass.all - ineligibles).find_all do |indiv|
55
+ if indiv.life_range
56
+ !indiv.can_procreate_during?(send("#{LINEAGE2PARENT[lineage]}_birth_range"))
57
+ else
58
+ false
59
+ end
60
+ end
61
+ end
62
+ end
63
+ ineligibles
64
+ end
65
+ end
66
+ end
67
+
68
+ generate_method_ineligible_grandparent(:paternal,:father)
69
+ generate_method_ineligible_grandparent(:paternal,:mother)
70
+ generate_method_ineligible_grandparent(:maternal,:father)
71
+ generate_method_ineligible_grandparent(:maternal,:mother)
72
+
73
+ # list of individual who cannot be children: ancestors, children, full siblings, theirself and all individuals with father or mother according to self's sex
74
+ # @return [Array]
75
+ def ineligible_children
76
+ ineligibles = []
77
+ ineligibles |= ancestors | children | siblings | [self] | gclass.all_with(SEX2PARENT[ssex]) if gclass.ineligibility_level >= PEDIGREE
78
+ if gclass.ineligibility_level >= PEDIGREE_AND_DATES and fertility_range
79
+ ineligibles |= (gclass.all - ineligibles).find_all{ |indiv| !can_procreate_during?(indiv.birth_range)}
80
+ end
81
+ ineligibles
82
+ end
83
+
84
+ # list of individual who cannot be full siblings: ancestors, descendants, siblings, theirself and all individuals with different father or mother
85
+ # @return [Array]
86
+ def ineligible_siblings
87
+ ineligibles = []
88
+ if gclass.ineligibility_level >= PEDIGREE
89
+ ineligibles |= ancestors | descendants | siblings | [self]
90
+ ineligibles |= (father ? gclass.all_with(:father).where.not(father_id: father) : [])
91
+ ineligibles |= (mother ? gclass.all_with(:mother).where.not(mother_id: mother) : [])
92
+ end
93
+ if gclass.ineligibility_level >= PEDIGREE_AND_DATES
94
+ # if a parent is present ineligible siblings are parent's ineligible children, otherwise try to estimate parent birth range.
95
+ # If it's possible ineligible siblings are all individuals whose life range overlaps parent birth range
96
+ [:father,:mother].each do |parent|
97
+ if p = send(parent)
98
+ ineligibles |= p.ineligible_children
99
+ elsif parent_fertility_range = send("#{parent}_fertility_range")
100
+ remainings = gclass.all - ineligibles
101
+ ineligibles |= remainings.find_all do |indiv|
102
+ if ibr = indiv.birth_range
103
+ !parent_fertility_range.overlaps? ibr
104
+ else
105
+ false
106
+ end
107
+ end
108
+ end
109
+ end
110
+ else
111
+ end
112
+ ineligibles
113
+ end
114
+
115
+ end
116
+ end