selfish_associations 0.0.0

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