Permalink
Browse files

Feature: Support for many-to-many associations (#419)

  • Loading branch information...
mereghost authored and jodosha committed Aug 4, 2017
1 parent 1fcf545 commit d1ad5b59e878a2be49805f96e0a95af1441cd258
@@ -1,6 +1,7 @@
require 'rom-sql'
require 'hanami/model/associations/has_many'
require 'hanami/model/associations/belongs_to'
require 'hanami/model/associations/many_to_many'

module Hanami
module Model
@@ -24,6 +25,8 @@ def self.build(repository, target, subject)
# @api private
def self.lookup(association)
case association
when ROM::SQL::Association::ManyToMany
Associations::ManyToMany
when ROM::SQL::Association::OneToMany
Associations::HasMany
when ROM::SQL::Association::ManyToOne
@@ -15,8 +15,9 @@ def initialize(repository, &blk)

# @since 0.7.0
# @api private
def has_many(relation, *)
def has_many(relation, **args)
@repository.__send__(:relations, relation)
@repository.__send__(:relations, args[:through]) if args[:through]
end

# @since x.x.x
@@ -0,0 +1,175 @@
module Hanami
module Model
module Associations
# Many-To-Many association
#
# @since 0.7.0
# @api private
class ManyToMany
# @since 0.7.0
# @api private
def self.schema_type(entity)
type = Sql::Types::Schema::AssociationType.new(entity)
Types::Strict::Array.member(type)
end

# @since x.x.x
# @api private
attr_reader :repository

# @since x.x.x
# @api private
attr_reader :source

# @since x.x.x
# @api private
attr_reader :target

# @since x.x.x
# @api private
attr_reader :subject

# @since x.x.x
# @api private
attr_reader :scope

# @since x.x.x
# @api private
attr_reader :through

def initialize(repository, source, target, subject, scope = nil)
@repository = repository
@source = source
@target = target
@subject = subject.to_hash unless subject.nil?
@through = relation(source).associations[target].through.to_sym
@scope = scope || _build_scope
freeze
end

def to_a
scope.to_a
end

def map(&blk)
to_a.map(&blk)
end

def each(&blk)
scope.each(&blk)
end

def count
scope.count
end

def where(condition)
__new__(scope.where(condition))
end

# @since x.x.x
# @api private
# Return the association table object. Would need an aditional query to return the entity
def add(*data) # Can receive an array of hashes with pks.
command(:create, relation(through), use: [:timestamps])
.call(associate(data.map(&:to_h)))
end

# @since x.x.x
# @api private
# disabled until I figure out a better way to do this
# rubocop:disable Metrics/AbcSize
def remove(id)
association_record = relation(through)
.where(target_foreign_key => id, source_foreign_key => subject.fetch(source_primary_key))
.one
if association_record
ar_id = association_record.public_send relation(through).primary_key
command(:delete, relation(through), use: [:timestamps]).by_pk(ar_id).call
end
end
# rubocop:enable Metrics/AbcSize

private

# @since x.x.x
# @api private
def container
repository.container
end

# @since x.x.x
# @api private
def relation(name)
repository.relations[name]
end

# @since x.x.x
# @api private
def command(target, relation, options = {})
repository.command(target, relation, options)
end

# @since x.x.x
# @api private
def associate(data)
relation(target)
.associations[source]
.associate(container.relations, data, subject)
end

# @since x.x.x
# @api private
def source_primary_key
association_keys[0].first
end

# @since x.x.x
# @api private
def source_foreign_key
association_keys[0].last
end

# @since x.x.x
# @api private
def association_keys
relation(source)
.associations[target]
.__send__(:join_key_map, container.relations)
end

# @since x.x.x
# @api private
def target_foreign_key
association_keys[1].first
end

# @since x.x.x
# @api private
def target_primary_key
association_keys[1].last
end

# @since x.x.x
# @api private
# rubocop:disable Metrics/AbcSize
def _build_scope
result = relation(target).qualified
unless subject.nil?
result = result
.join(through, target_foreign_key => target_primary_key)
.where(source_foreign_key => subject.fetch(source_primary_key))
end
result.as(Model::MappedRelation.mapper_name)
end
# rubocop:enable Metrics/AbcSize

# @since x.x.x
# @api private
def __new__(new_scope)
self.class.new(repository, source, target, subject, new_scope)
end
end
end
end
end
@@ -1,4 +1,4 @@
RSpec.describe 'Associations (belongs_To)' do
RSpec.describe 'Associations (belongs_to)' do
it "returns nil if association wasn't preloaded" do
repository = BookRepository.new
book = repository.create(name: 'L')
@@ -0,0 +1,107 @@
RSpec.describe 'Associations (has_many :through)' do
#### REPOS
let(:books) { BookRepository.new }
let(:categories) { CategoryRepository.new }
let(:ontologies) { BookOntologyRepository.new }

### ENTITIES
let(:book) { books.create(title: 'Ontology: Encyclopedia of Database Systems', on_sale: false) }
let(:category) { categories.create(name: 'information science') }

it "returns nil if association wasn't preloaded" do
found = books.find(book.id)
expect(found.categories).to be(nil)
end

it 'preloads the associated record' do
ontologies.create(book_id: book.id, category_id: category.id)
found = books.find_with_categories(book.id)

expect(found).to eq(book)
expect(found.categories).to eq([category])
end

it 'returns an array of Categories' do
ontologies.create(book_id: book.id, category_id: category.id)

found = books.categories_for(book)
expect(found).to eq([category])
end

it 'returns the count of on sale associated books' do
on_sale = books.create(title: 'The Sense of Style', on_sale: true)
ontologies.create(book_id: on_sale.id, category_id: category.id)

expect(categories.on_sales_books_count(category)).to eq(1)
end

context '#add' do
it 'adds an object to the collection' do
books.add_category(book, category)

found_book = books.find_with_categories(book.id)
found_category = categories.find_with_books(category.id)

expect(found_book).to eq(book)
expect(found_book.categories).to eq([category])
expect(found_category).to eq(category)
expect(found_category.books).to eq([book])
end

it 'associates a collection of records' do
other_book = books.create(title: 'Ontological Engineering')
categories.add_books(category, book, other_book)
found = categories.find_with_books(category.id)

expect(found.books).to match_array([book, other_book])
end
end

context '#remove' do
it 'removes the desired association' do
to_remove = books.create(title: 'The Life of a Stoic')
books.add_category(to_remove, category)

categories.remove_book(category, to_remove.id)
found = categories.find_with_books(category.id)

expect(found.books).to_not include(to_remove)
end
end

context 'collection methods' do
it 'returns an array of books' do
ontologies.create(book_id: book.id, category_id: category.id)

actual = categories.books_for(category).to_a
expect(actual).to eq([book])
end

it 'iterates through the categories' do
ontologies.create(book_id: book.id, category_id: category.id)
actual = []

categories.books_for(category).each do |book|
expect(book).to be_an_instance_of(Book)
actual << book
end

expect(actual).to eq([book])
end

it 'iterates through the books and returns an array' do
ontologies.create(book_id: book.id, category_id: category.id)

actual = categories.books_for(category).map(&:id)
expect(actual).to eq([book.id])
end

it 'returns the count of the associated books' do
other_book = books.create(title: 'Practical Ontologies for Information Professionals')
ontologies.create(book_id: book.id, category_id: category.id)
ontologies.create(book_id: other_book.id, category_id: category.id)

expect(categories.books_count(category)).to eq(2)
end
end
end
@@ -10,6 +10,12 @@ class Author < Hanami::Entity
class Book < Hanami::Entity
end

class Category < Hanami::Entity
end

class BookOntology < Hanami::Entity
end

class Operator < Hanami::Entity
end

@@ -134,9 +140,59 @@ def book_for(author, id)
end
end

class BookOntologyRepository < Hanami::Repository
associations do
belongs_to :books
belongs_to :categories
end
end

class CategoryRepository < Hanami::Repository
associations do
has_many :books, through: :book_ontologies
end

def books_for(category)
assoc(:books, category)
end

def on_sales_books_count(category)
assoc(:books, category).where(on_sale: true).count
end

def books_count(category)
assoc(:books, category).count
end

def find_with_books(id)
aggregate(:books).where(id: id).as(Category).one
end

def add_books(category, *books)
assoc(:books, category).add(*books)
end

def remove_book(category, book_id)
assoc(:books, category).remove(book_id)
end
end

class BookRepository < Hanami::Repository
associations do
belongs_to :author
has_many :categories, through: :book_ontologies
end

def add_category(book, category)
assoc(:categories, book).add(category)
end

def categories_for(book)
assoc(:categories, book).to_a
end

def find_with_categories(id)
aggregate(:categories).where(id: id).as(Book).one
end

def find_with_author(id)
Oops, something went wrong.

0 comments on commit d1ad5b5

Please sign in to comment.