selfish_associations 0.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/selfish_associations/association_traverser.rb +81 -0
- data/lib/selfish_associations/associations/association.rb +97 -0
- data/lib/selfish_associations/associations/has_many.rb +9 -0
- data/lib/selfish_associations/associations/has_one.rb +11 -0
- data/lib/selfish_associations/base.rb +32 -0
- data/lib/selfish_associations/builder.rb +23 -0
- data/lib/selfish_associations/path_merger.rb +66 -0
- data/lib/selfish_associations/scope_reader.rb +55 -0
- data/lib/selfish_associations/selfish_exception.rb +4 -0
- data/lib/selfish_associations.rb +7 -0
- metadata +53 -0
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,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,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: []
|