genealogy 1.5.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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