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 +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: []
|