selfish_associations 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 27c26aa51df5427a9e49e1cab0579b2221dff05d
4
+ data.tar.gz: c097635034e3eacb38684804688c2299ae5eab7f
5
+ SHA512:
6
+ metadata.gz: e859a4392f780a056d70379f3fc5bc0a1e965422f852421354240cf3dabe36a65bf3da912f1b5dbb1d1c85c24187a06ce00059cdc4251bff8093b68709b52306
7
+ data.tar.gz: b2042d49ad28fc35e24e6752f5823cf03bb4c0e6d6a09f0aa5487ecce69090fbfd3803540168f9b2e330db02d25536302221228bbda6161b9c579a535724b8a3
@@ -0,0 +1,81 @@
1
+ # A SelfishAssociations::Traverser allows you to explore an ActiveRecord's associations
2
+ # You can call any method on an Traverser that is a valid association or column
3
+ # name of the initialized class. For associations, the return value is another
4
+ # Traverser for the new node, allowing you to iterate this process. For a column
5
+ # name, the return values is an Arel::Node representing the endpoint of the traverse.
6
+ #
7
+ # E.g., for a class Foo that has_one :bar, where Bar has_one :baz
8
+ # t = AssociationTraverser.new(Foo)
9
+ # t.bar.baz.id
10
+ # # => Arel::Attribute(table: "bazs", field: "id")
11
+ #
12
+ # As a Traverser gets iterated accross assocaitions, it collects every model that
13
+ # it has seen. You can then call :associations! to fetch these assocaitions.
14
+ #
15
+ # e.g., continuing from above
16
+ # # t.associations!
17
+ # # => {:bar => :baz}
18
+ #
19
+ # A Traverser keeps track of all paths it traversed, and each terminal node gets returned
20
+ # as an Arel Node. Thus you can use it in a series of calls to return the Arel Nodes, and
21
+ # you can subsequently call :associations! on the Traverser to gain access to all
22
+ # paths specified by the Hash:
23
+ #
24
+ # t = AssociationTraverser.new(Foo)
25
+ # {baz_id: t.bar.baz.id, quz_name: t.bar.qux.name}
26
+ # => {baz_id: #<Arel::Attributes(table: "bazs", name: "id")>, quz_name: <#Arel::Attributes(table: quxes, name: "name")>}
27
+ # t.associations!
28
+ # {:bar => [:baz, :qux]}
29
+ #
30
+ # You can also pass merge: false to :associations! to return associations as an unflattened Array:
31
+ # t.associations!(merge: false)
32
+ # [[:bar, :baz], [:bar, :qux]]
33
+
34
+
35
+ # We use bang method names to avoid conflicting with valid association and column names
36
+ module SelfishAssociations
37
+ class AssociationTraverser < BasicObject
38
+ def initialize(klass)
39
+ klass.is_a?(::Class) or ::Kernel.raise ::ArgumentError, "Input must be a Class"
40
+ @original = klass
41
+ @associations = []
42
+ reset!
43
+ end
44
+
45
+ def reset!
46
+ @path = []
47
+ @klass = @original
48
+ end
49
+
50
+ # Return associations traversed
51
+ # Default behavior is to return an array of each path (Array of associations)
52
+ # Use argument merge = true to return a Hash where duplicate keys are merged
53
+ def associations!(merge: true)
54
+ merge ? PathMerger.new(@associations).merge : @associations
55
+ end
56
+
57
+ # Method Missing pattern (reluctantly).
58
+ # Really we could initialize anew at each node and pre-define all methods
59
+ # But this actually seems more lightweight.
60
+ # Intercept any method to check if it is an association or a column
61
+ # If Association, store current node and iterate to the association.
62
+ # If it is a column, return an Arel::Node representing that value
63
+ # Else, raise NoMethodError
64
+ def method_missing(method, *args)
65
+ if @klass.column_names.include?(method.to_s)
66
+ @associations << @path if @path.present?
67
+ node = @klass.arel_table[method]
68
+ reset!
69
+ return node
70
+ elsif @klass.reflect_on_association(method)
71
+ @path << method
72
+ @klass = @klass.reflect_on_association(method).klass
73
+ return self
74
+ else
75
+ message = "No association or field named #{method} found for class #{@klass}"
76
+ reset!
77
+ ::Kernel.raise ::NoMethodError, message
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,97 @@
1
+ # A SelfishAssociation::Association is the main engine behind generating SelfishAssociations.
2
+ # They are primarily defined using the has_one_selfish, has_many_selfish methods.
3
+ # But if you want to debug and explor, you can initailize a SelfishAssociation::Association
4
+ # easily with a name and a model (class), options scope and other options.
5
+ #
6
+ # assoc = SelfishAssociations::Association.new(:native_transcript, Video, ->(vid){where language_id: vid.language_id}, class_name: "Transcript")
7
+ #
8
+ # Use the :joins, :find, :create, and :matches methods to play!
9
+
10
+ module SelfishAssociations
11
+ class Association
12
+ def initialize(name, model, scope = nil, options = {})
13
+ options = options.symbolize_keys
14
+ @name = name.to_s
15
+ @model = model
16
+ @scope = scope
17
+ @foreign_class_name = (options[:class_name] || name.to_s.classify).to_s
18
+ @foreign_key = options[:foreign_key] == false ? false : (options[:foreign_key] || @model.name.foreign_key).to_sym
19
+
20
+ validate
21
+
22
+ @reader = SelfishAssociations::ScopeReader.new(@model)
23
+ # Can't use ivar inside Lambda
24
+ foreign_key = @foreign_key
25
+ add_scope(->(selph){ where foreign_key => selph.id }) if @foreign_key.present?
26
+ add_scope(@scope) if @scope.present?
27
+ end
28
+
29
+ def inspect
30
+ "#<#{self.class}:#{self.object_id} @name=#{@name} @model=#{@model} @foreign_class=#{foreign_class} @foreign_key=#{@foreign_key}>"
31
+ end
32
+
33
+ def foreign_class
34
+ @foreign_class ||= self.class.const_get(@foreign_class_name)
35
+ end
36
+
37
+ def add_scope(scope)
38
+ @reader.read(scope)
39
+ end
40
+
41
+ def join
42
+ conditions = arelize_conditions(@reader.conditions_for_find)
43
+ arel_join = @model.arel_table.join(foreign_class.arel_table).on(conditions).join_sources
44
+ @model.joins(joins_for_find).joins(arel_join).merge(foreign_class.all)
45
+ end
46
+
47
+ def create(instance)
48
+ foreign_class.create(instance_create_attributes(instance))
49
+ end
50
+
51
+ def matches(instance)
52
+ foreign_class.where(instance_find_conditions(instance))
53
+ end
54
+
55
+
56
+ private
57
+
58
+ def arelize_conditions(conditions)
59
+ conditions.map do |foreign_field, node|
60
+ foreign_class.arel_table[foreign_field].eq(node)
61
+ end.reduce{|c1, c2| c1.and(c2)}
62
+ end
63
+
64
+ def instance_find_conditions(instance)
65
+ instance_conditions(instance, @reader.conditions_for_find)
66
+ end
67
+
68
+ def instance_create_attributes(instance)
69
+ instance_find_conditions(instance).merge(instance_conditions(instance, @reader.attribuets_for_create))
70
+ end
71
+
72
+ def joins_for_find
73
+ @reader.joins_for_find
74
+ end
75
+
76
+ def joins_for_create
77
+ @reader.joins_for_create
78
+ end
79
+
80
+ # TODO: does this even work for create??
81
+ def instance_conditions(instance, conditions)
82
+ # TODO: if no joins, we can just read fields off of instance
83
+ # TODO: if cached associations exist, we can read fields off those too
84
+ arel_conditions, static_conditions = conditions.partition{|field, value| value.is_a?(::Arel::Attribute)}.map(&:to_h)
85
+ selector = arel_conditions.map{|field, arel| arel.as(field.to_s)}
86
+ record = @model.joins(joins_for_find).select(selector).find_by(id: instance.id)
87
+ arel_conditions.keys.each{|k| static_conditions[k] = record[k]}
88
+ return static_conditions
89
+ end
90
+
91
+ def validate
92
+ # self.class.const_defined?(foreign_class) or raise SelfishAssociations::SelfishException, "No class named #{foreign_class} found."
93
+ @scope.nil? || @scope.is_a?(Proc) or raise SelfishAssociations::SelfishException, "Scope must be a Proc"
94
+ @model.is_a?(Class) && @model < ActiveRecord::Base or raise SelfishAssociations::SelfishException, "Tried to define a SelfishAssociation for an invalid object (#{@model})"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,9 @@
1
+ module SelfishAssociations
2
+ module Associations
3
+ class HasMany < SelfishAssociations::Association
4
+ def find(instance)
5
+ foreign_class.instance_exec(instance, @scope)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module SelfishAssociations
2
+ module Associations
3
+ class HasOne < SelfishAssociations::Association
4
+ def find(instance)
5
+ # TODO: just this? In fact, can we just combined this entirely with AR?
6
+ # foreign_class.instance_exec(instance, @scope).first
7
+ foreign_class.find_by(instance_find_conditions(instance))
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,32 @@
1
+ module SelfishAssociations
2
+ module Base
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :selfish_associations
7
+ self.selfish_associations = {}
8
+ attr_reader :selfish_association_cache
9
+ after_initialize :reset_selfish_association_cache
10
+ end
11
+
12
+ def reset_selfish_association_cache
13
+ @selfish_association_cache = {}
14
+ end
15
+
16
+ module ClassMethods
17
+ def has_one_selfish(name, scope = nil, **options)
18
+ SelfishAssociations::Builder.new(self).add_association(SelfishAssociations::Association::HasOne.new(name, self, scope, options))
19
+ end
20
+
21
+ def has_many_selfish(name, scope = nil, **options)
22
+ SelfishAssociations::Builder.new(self).add_association(SelfishAssociations::Association::HasMany.new(name, self, scope, options))
23
+ end
24
+
25
+ def selfish_joins(name)
26
+ assoc = self.selfish_associations[name] or raise SelfishException, "No selfish_associations named #{name} found, perhaps you misspelled it?"
27
+ assoc.join
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ module SelfishAssociations
2
+ class Builder
3
+ def initialize(model)
4
+ @model = model
5
+ end
6
+
7
+ def initialize_methods_class
8
+ @model.const_set("SelfishAssociationMethods", Module.new) unless defined? @model::SelfishAssociationMethods
9
+ @model.include @model::SelfishAssociationMethods if !ancestors.include?(@model::SelfishAssociationMethods)
10
+ end
11
+
12
+ def add_association(assoc)
13
+ @model.selfish_associations[name] = assoc
14
+
15
+ @model::SelfishAssociationMethods.class_eval do
16
+ define_method(name) do |reload = false|
17
+ return @selfish_association_cache[name] if !reload && @selfish_association_cache.key?(name)
18
+ @selfish_association_cache[name] = self.selfish_associations[name].find(self)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,66 @@
1
+ module SelfishAssociations
2
+ class PathMerger
3
+ attr_reader :paths
4
+
5
+ def initialize(paths)
6
+ @paths = paths.select(&:present?)
7
+ end
8
+
9
+ # Take array of arrays in @paths and turn into a nested hash
10
+ # Duplicate keys are collapsed into Array values
11
+ # Endpoint values are guaranteed to be Arrays
12
+ def merge
13
+ merged_paths = @paths.reduce({}){|merged, path| merge_paths(merged, hashify_path(path))}
14
+ return denillify(merged_paths)
15
+ end
16
+
17
+ # Convert each association index array into a 1-wide, n-deep Hash
18
+ # For algorithmic convenience (see below), applies a nil endpoint
19
+ # to all paths.
20
+ # So
21
+ # [:a, :b, :c]
22
+ # becomes
23
+ # {:a => {:b => {:c => nil}}}
24
+ def hashify_path(path)
25
+ path.reverse.reduce(nil){|path_partial, node| {node => path_partial}}
26
+ end
27
+
28
+ # Merge in a new hash path into the current merged hash paths.
29
+ # Dup keys are combined as a merged Hash themselves: we need recursion!
30
+ # Non-nil values are guaranteed to be Hashes because we've introduced the nil endpoint
31
+ # Therefore we can simply merge all non-nil values recursivelye
32
+ # So
33
+ # [[:a, :b, :c], [:a, :b], [:a, :x]
34
+ # which hashifies into (remembering that we add nil endpoints)
35
+ # [{:a => {:b => {:c => nil}}}, {:a => {:b => :nil}}, {:a => {:x => nil}}]
36
+ # now becomes
37
+ # {:a => {:b => {:c => nil}, {:x => nil}}}
38
+ def merge_paths(paths, new_path)
39
+ paths.merge!(new_path) do |parent_node, oldval, newval|
40
+ if oldval.nil? || newval.nil?
41
+ # If either old or new path ended at parent_node, use the other
42
+ oldval || newval
43
+ else
44
+ # Else both paths continue from this node: recurse down the path!
45
+ merge_paths(oldval, newval)
46
+ end
47
+ end
48
+ end
49
+
50
+ # Strip off the nil endpoints in a merged nillified Hash path Array.
51
+ # 1-deep paths get un-Hashed. I.e., {key => nil} becomes just key.
52
+ # >1-deep Hashes are guaranteed to have Hash values.
53
+ # So, from above,
54
+ # {:a => {:b => {:c => nil}, {:x => nil}}}
55
+ # now becomes
56
+ # {:a => [:x, {:b => :c}]}
57
+ def denillify(paths)
58
+ singles, hashes = paths.keys.partition{|k| paths[k].nil?}
59
+ hashes.each{|k| paths[k] = denillify(paths[k])}
60
+ return_array = singles
61
+ return_array << paths.slice(*hashes) if hashes.present?
62
+ return return_array.length == 1 ? return_array.first : return_array
63
+ end
64
+
65
+ end
66
+ end
@@ -0,0 +1,55 @@
1
+ # A SelfishAssociations::ScopeReader is responsible for reading a scope lambda, such
2
+ # as `->(foo){ where language_id: foo.language_id}`. The reader collects information
3
+ # about the scope in order to apply it either to an instnace-level find, e.g.,
4
+ # `where(language_id: 1)`, or to a joins, e.g., `where("language_id = foos.language_id")
5
+ # It does this by responding to `where` and `create_with` methods that you might
6
+ # use in a scope and keeping track of the resulitng Arel Nodes in order to build
7
+ # the find or joins query, using an SelfishAssociations::AssociationTraverser to interpret
8
+ # the conditions themselves.
9
+
10
+ module SelfishAssociations
11
+ class ScopeReader < BasicObject
12
+ attr_reader :traverser
13
+
14
+ def initialize(klass)
15
+ @traverser = ::SelfishAssociations::AssociationTraverser.new(klass)
16
+ @conditions_for_find = {}
17
+ @joins_for_find = []
18
+ @attribuets_for_create = {}
19
+ @joins_for_create = []
20
+ end
21
+
22
+ def read(scope)
23
+ args = scope.arity == 0 ? [] : [@traverser]
24
+ instance_exec(*args, &scope)
25
+ end
26
+
27
+ def where(conditions)
28
+ @conditions_for_find.merge!(conditions)
29
+ @joins_for_find += @traverser.associations!(merge: false)
30
+ @traverser.reset!
31
+ return self
32
+ end
33
+
34
+ def create_with(conditions)
35
+ @attribuets_for_create.merge!(conditions)
36
+ @joins_for_create += @traverser.associations!
37
+ @traverser.reset!
38
+ return self
39
+ end
40
+
41
+ attr_reader :conditions_for_find, :attribuets_for_create
42
+
43
+ def joins_for_find
44
+ ::SelfishAssociations::PathMerger.new(@joins_for_find).merge
45
+ end
46
+
47
+ def joins_for_create
48
+ ::SelfishAssociations::PathMerger.new(@joins_for_create).merge
49
+ end
50
+
51
+ # TODO: implement `.where.not`
52
+ # TODO: join to other SelfishAssociations associations
53
+
54
+ end
55
+ end
@@ -0,0 +1,4 @@
1
+ module SelfishAssociations
2
+ class SelfishException < StandardError
3
+ end
4
+ end
@@ -0,0 +1,7 @@
1
+ ActiveRecord::Base.include(SelfishAssociations::Base)
2
+
3
+ require 'selfish_associations/association_traverser'
4
+ require 'selfish_associations/association'
5
+ require 'selfish_associations/path_merger'
6
+ require 'selfish_associations/scope_reader'
7
+ require 'selfish_associations/selfish_exception'
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: selfish_associations
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Schwartz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-05-04 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Create ActiveRecord-like associations with self-awareness
14
+ email: ozydingo@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - lib/selfish_associations.rb
20
+ - lib/selfish_associations/association_traverser.rb
21
+ - lib/selfish_associations/associations/association.rb
22
+ - lib/selfish_associations/associations/has_many.rb
23
+ - lib/selfish_associations/associations/has_one.rb
24
+ - lib/selfish_associations/base.rb
25
+ - lib/selfish_associations/builder.rb
26
+ - lib/selfish_associations/path_merger.rb
27
+ - lib/selfish_associations/scope_reader.rb
28
+ - lib/selfish_associations/selfish_exception.rb
29
+ homepage: https://github.com/ozydingo/selfish_associations
30
+ licenses:
31
+ - MIT
32
+ metadata: {}
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubyforge_project:
49
+ rubygems_version: 2.4.5
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: Selfish Associations
53
+ test_files: []