Добавление меток и древовидных категорий в Rails приложение на MongoMapper
Продолжение статьи Пример написания приложения на Rails с использованием MongoDB и MongoMapper вместо MySQL и ActiveRecord.
В приложении Shapado метки реализованы добавлением в модель поля tags с типом Array. Это поле содержит массив меток. В нашем случае:
...
key :tags, Array, :default => []
...
end
Поиск трат помеченных меткой осуществляется так:
self.find(:all, :conditions => conditions.merge({:tags => tag}))
end
А список всех используемых меток, например для облака меток, так:
@tag_cloud_code ||= RAILS_ROOT + "/app/javascripts/tag_cloud.js"
self.database.eval(File.read(@tag_cloud_code), conditions, limit)
end
В файле RAILS_ROOT + "/app/javascripts/tag_cloud.js" лежит такой скрипт:
var counts = db.eval(
function(q){
var counts = {};
db.spendings.find(q).limit(500).forEach(
function(p){
if ( p.tags ){
for ( var i=0; i<p.tags.length; i++ ){
var name = p.tags[i];
counts[name] = 1 + ( counts[name] || 0 );
}
}
}
);
return counts;
},
q
);
// maybe sort to by nice
var sorted = [];
for ( var tag in counts ){
sorted.push( { name : tag , count : counts[tag] } )
}
return sorted.sort(
function(l,r){
return r.count - l.count;
}
).slice(0,limit||30);
}
Функция tagCloud просто перебирает все траты и извлекает из них уникальные метки, заодно подсчитывая количество трат приходящихся на метку. Потом сортирует по убыванию количества трат и обрезает массив.
Кстати, код javascript можно хранить на сервере БД…
К сожалению, этот метод подходит если только используются метки написанные латиницей. Если присутствуют к примеру русские буквы, то нужно хранить еще и поле slug для каждой метки. Потому что не латинские буквы в URL использовать не удобно. У нас метки могут быть на русском языке, поэтому создадим модель Tag. А в поле tags модели Spending будем хранить массив идентификаторов меток. В модель Tag, помимо полей name и slug, добавим поле spendings_count, которое будет содержать количество трат помеченных меткой. Это поле будет обновляться при добавлении меток к трате или удалении траты.
include MongoMapper::Document
include SafeAttributes
key :name, String, :required => true
key :slug, String, :required => true
key :spendings_count, Integer, :default => 0, :required => true
before_validation :sluggize
validates_uniqueness_of :slug
validates_uniqueness_of :name
def to_param
self.slug
end
def self.find_or_create_by_name(name)
find_by_slug(slug_from(name)) || create(:name => name.downcase)
end
private
def self.slug_from(key)
Russian::translit(key).gsub(/[^A-Za-z0-9\s\-]/, "")[0,40].strip.gsub(/\s+/, "-").downcase
end
def sluggize
self.slug = self.class.slug_from(self.name)
self.name.downcase!
end
end
class Spending
...
key :tags, Array, :default => []
after_destroy :decrement_spendings_count
def tag_list
Tag.find(self.tags)
end
def tag_list=(tags)
decrement_spendings_count
self.tags = []
tags.collect {|n| self.tags << Tag.find_or_create_by_name(n)._id}
increment_spendings_count
save
end
...
def self.tag_cloud(conditions = {}, limit = 30)
Tag.find(:all, :conditions => conditions, :order => 'spendings_count desc', :limit => limit)
end
private
def decrement_spendings_count
Tag.collection.update({:_id => {'$in' => self.tags}}, {'$inc' => {:spendings_count => -1}}, :multi => true) unless self.tags.size.nil?
end
def increment_spendings_count
Tag.collection.update({:_id => {'$in' => self.tags}}, {'$inc' => {:spendings_count => 1}}, :multi => true) unless self.tags.size.nil?
end
end
Теперь выбрать траты помеченные меткой можно так:
tag = Tag.find_by_slug(tag_slug)
self.by_tag(tag, conditions) if tag
end
def self.by_tag(tag, conditions = {})
self.find(:all, :conditions => conditions.merge({:tags => tag._id})) if tag
end
А список всех меток, так:
Tag.find(:all, :conditions => conditions, :order => 'spendings_count desc', :limit => limit)
end
Добавлять метки к трате можно так:
С метками разобрались. Теперь возьмемся за древовидные категории. Вообще разработчики mongo предлагают несколько вариантов хранения деревьев в mongoDB:
Хранить полное дерево в документе
comments: [
{by: "mathias", text: "...", replies: []}
{by: "eliot", text: "...", replies: [
{by: "mike", text: "...", replies: []}
{by: "undr", text: "...", replies: [
{by: "eliot", text: "...", replies: []}
]}
]}
]
}
Этот метод хорош для деревьев, которые нужно отображать целиком на странице. Например, дерево комментариев. Но он становится неудобным если необходимо производить поиск по дереву. К тому же есть ограничение на объем данных хранимых в документе, поэтому размер вашего дерева не должен превышать 4Mb. Нам этот метод не подходит.
Хранить полный путь от корня к элементу в каждом элементе дерева
{"_id":"Food", "path":["Food"]},
{"_id":"Fruit", "path":["Food","Fruit"]},
{"_id":"Red", "path":["Food","Fruit","Red"]},
{"_id":"Cherry", "path":["Food","Fruit","Red","Cherry"]},
{"_id":"Tomato", "path":["Food","Fruit","Red","Tomato"]},
{"_id":"Yellow", "path":["Food","Fruit","Yellow"]},
{"_id":"Banana", "path":["Food","Fruit","Yellow","Banana"]},
{"_id":"Meat", "path":["Food","Meat"]},
{"_id":"Beef", "path":["Food","Meat","Beef"]},
{"_id":"Pork", "path":["Food","Meat","Pork"]}
]
Этот метод предполагает различные варианты хранения пути. Как массив идентификаторов или как строка идентификаторов разделенных каким-либо символом. В качестве идентификатора может выступать ObjectId или его строковое представление, а так же любое уникальное поле документа, например, slug. К примеру, для веб страничек использующих в качестве идентификатора slug, путь может быть строкой URL ("first-page/second-page/etc")
Хранить в каждом элементе ссылки на дочерние
{"_id":"Food", "children":["Fruit", "Meat"]},
{"_id":"Fruit", "children":["Red","Yellow"]},
{"_id":"Red", "children":["Cherry","Tomato"]},
{"_id":"Cherry", "children":[]},
{"_id":"Tomato", "children":[]},
{"_id":"Yellow", "children":["Banana"]},
{"_id":"Banana", "children":[]},
{"_id":"Meat", "children":["Beef","Pork"]},
{"_id":"Beef", "children":[]},
{"_id":"Pork", "children":[]}
]
Хранить ссылку на родительский элемент
{"_id":"Food", "parent":""},
{"_id":"Fruit", "parent":"Food"},
{"_id":"Red", "parent":"Fruit"},
{"_id":"Cherry", "parent":"Red"},
{"_id":"Tomato", "parent":"Red"},
{"_id":"Yellow", "parent":"Fruit"},
{"_id":"Banana", "parent":"Yellow"},
{"_id":"Meat", "parent":"Food"},
{"_id":"Beef", "parent":"Meat"},
{"_id":"Pork", "parent":"Meat"}
]
и так далее…
Подробнее можно прочитать в этой статье.
Нам вполне подойдет комбинированный вариант из второго и последнего способа. Тем более что писать самому ничего не придется, а можно воспользоваться готовым плагином для Rails mongo_mapper_acts_as_tree
Или в файл config/environment.rb добавим gem
В консоли:
undr:spending undr$ rake gems:unpack
В модель Category добавляем:
...
include MongoMapper::Acts::Tree
acts_as_tree :order => 'name asc'
...
end
Теперь в нашем распоряжении модель обладающая методами необходимыми для работы с деревом.
Создание дерева
Category.create(:name => "Оплата интернета", :parent => comm)
Category.create(:name => "Оплата мобильного телефона", :parent => comm)
transport = Category.create(:name => "Транспортные расходы")
Category.create(:name => "Бензин", :parent => transport)
Category.create(:name => "Общественный транспорт", :parent => transport)
Category.create(:name => "Штрафы в ГАИ", :parent => transport)
eats = Category.create(:name => "Питание")
Category.create(:name => "Продукты домой", :parent => eats)
Category.create(:name => "Рестораны", :parent => eats)
Category.create(:name => "Fast food", :parent => eats)
rest = Category.create(:name => "Отдых")
tourism = Category.create(:name => "Путешевствия", :parent => rest)
Category.create(:name => "Туры", :parent => tourism)
Category.create(:name => "Пикники", :parent => rest)
Category.create(:name => "Развлечения", :parent => rest)
auto = Category.create(:name => "Автомобиль")
Category.create(:name => "Ремонт", :parent => auto)
Category.create(:name => "Upgrade", :parent => auto)
Category.create(:name => "Жилье")
Category.create(:name => "Подарки")
Category.create(:name => "Прочее")
Выборка корневых элементов
=> [#<Category name: "Автомобиль", slug: "avtomobil", depth: 0, _id: 4b4f6d5cb34fb4461e000011, parent_id: nil, path: []>, #<Category name: "Жилье", slug: "zhile", depth: 0, _id: 4b4f6d5cb34fb4461e000014, parent_id: nil, path: []>, #<Category name: "Отдых", slug: "otdyh", depth: 0, _id: 4b4f6d5cb34fb4461e00000c, parent_id: nil, path: []>, #<Category name: "Питание", slug: "pitanie", depth: 0, _id: 4b4f6d5cb34fb4461e000008, parent_id: nil, path: []>, #<Category name: "Подарки", slug: "podarki", depth: 0, _id: 4b4f6d5cb34fb4461e000015, parent_id: nil, path: []>, #<Category name: "Прочее", slug: "prochee", depth: 0, _id: 4b4f6d5cb34fb4461e000016, parent_id: nil, path: []>, #<Category name: "Связь", slug: "svyaz", depth: 0, _id: 4b4f6d5cb34fb4461e000001, parent_id: nil, path: []>, #<Category name: "Транспортные расходы", slug: "transportnye-rashody", depth: 0, _id: 4b4f6d5cb34fb4461e000004, parent_id: nil, path: []>]
>> node = Category.find_by_slug('benzin')
=> #<Category name: "Бензин", slug: "benzin", depth: 1, _id: 4b4f6d5cb34fb4461e000005, parent_id: 4b4f6d5cb34fb4461e000004, path: [4b4f6d5cb34fb4461e000004]>
>> root = node.root
=> #<Category name: "Транспортные расходы", slug: "transportnye-rashody", depth: 0, _id: 4b4f6d5cb34fb4461e000004, parent_id: nil, path: []>
>>
Предки элемента
=> [#<Category name: "Транспортные расходы", slug: "transportnye-rashody", depth: 0, _id: 4b4f6d5cb34fb4461e000004, parent_id: nil, path: []>]
>> node.self_and_ancestors
=> [#<Category name: "Транспортные расходы", slug: "transportnye-rashody", depth: 0, _id: 4b4f6d5cb34fb4461e000004, parent_id: nil, path: []>, #<Category name: "Бензин", slug: "benzin", depth: 1, _id: 4b4f6d5cb34fb4461e000005, parent_id: 4b4f6d5cb34fb4461e000004, path: [4b4f6d5cb34fb4461e000004]>]
>>
Дочерние элементы
=> #<Category name: "Отдых", slug: "otdyh", depth: 0, _id: 4b4f6d5cb34fb4461e00000c, parent_id: nil, path: []>
>> root2.children
=> [#<Category name: "Пикники", slug: "pikniki", depth: 1, _id: 4b4f6d5cb34fb4461e00000f, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Путешевствия", slug: "puteshevstviya", depth: 1, _id: 4b4f6d5cb34fb4461e00000d, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Развлечения", slug: "razvlecheniya", depth: 1, _id: 4b4f6d5cb34fb4461e000010, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>]
>> root2.descendants
=> [#<Category name: "Пикники", slug: "pikniki", depth: 1, _id: 4b4f6d5cb34fb4461e00000f, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Путешевствия", slug: "puteshevstviya", depth: 1, _id: 4b4f6d5cb34fb4461e00000d, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Развлечения", slug: "razvlecheniya", depth: 1, _id: 4b4f6d5cb34fb4461e000010, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Туры", slug: "tury", depth: 2, _id: 4b4f6d5cb34fb4461e00000e, parent_id: 4b4f6d5cb34fb4461e00000d, path: [4b4f6d5cb34fb4461e00000c, 4b4f6d5cb34fb4461e00000d]>]
>> root2.self_and_descendants
=> [#<Category name: "Отдых", slug: "otdyh", depth: 0, _id: 4b4f6d5cb34fb4461e00000c, parent_id: nil, path: []>, #<Category name: "Пикники", slug: "pikniki", depth: 1, _id: 4b4f6d5cb34fb4461e00000f, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Путешевствия", slug: "puteshevstviya", depth: 1, _id: 4b4f6d5cb34fb4461e00000d, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Развлечения", slug: "razvlecheniya", depth: 1, _id: 4b4f6d5cb34fb4461e000010, parent_id: 4b4f6d5cb34fb4461e00000c, path: [4b4f6d5cb34fb4461e00000c]>, #<Category name: "Туры", slug: "tury", depth: 2, _id: 4b4f6d5cb34fb4461e00000e, parent_id: 4b4f6d5cb34fb4461e00000d, path: [4b4f6d5cb34fb4461e00000c, 4b4f6d5cb34fb4461e00000d]>]
>>
И всевозможные проверки и свойства
=> true
>> root.is_or_is_ancestor_of?(node)
=> true
>> node.is_descendant_of?(root)
=> true
>> node.depth
=> 1
>> node.path
=> [4b4f6d5cb34fb4461e000004]
>> node.parent
=> #<Category name: "Транспортные расходы", slug: "transportnye-rashody", depth: 0, _id: 4b4f6d5cb34fb4461e000004, parent_id: nil, path: []>
>>
И так далее…
Важно! Есть одно маленькое но! mongo_mapper_acts_as_tree требует ActiveSupport версии 2.3 или более. Поэтому я обновил Rails.
Rails 2.3.5
Теперь подправим контроллеры и шаблоны. Добавим реализацию метода tag в контроллере SpendingsController и изменим метод category так чтобы он добавлял в список траты принадлежащие дочерним категориям.
@current_category = Category.find_by_slug(params[:id])
@spendings = Spending.paginate( :per_page => 30,
:page => params[:page] || 1,
:conditions => {:category_id => { '$in' => @current_category.self_and_ancestors.collect(&:_id)}},
:order => 'spend_at desc')
@grouped_spendings = @spendings.group_by {|s| s.spend_at.beginning_of_day}
respond_to do |format|
format.html # category.html.haml
format.xml { render :xml => @spendings }
end
end
def tag
@current_tag = Tag.find_by_slug(params[:id])
@spendings = Spending.paginate( :per_page => 30,
:page => params[:page] || 1,
:conditions => {:tags => @current_tag._id},
:order => 'spend_at desc')
@grouped_spendings = @spendings.group_by {|s| s.spend_at.beginning_of_day}
respond_to do |format|
format.html # tag.html.haml
format.xml { render :xml => @spendings }
end
end
В шаблон app/views/spendings/_spending.html.haml добавим отображение меток.
-spending.tag_list.each do |tag|
=link_to tag.name, spendings_by_tag_path(tag)
А в шаблон app/views/layouts/default.html.haml добавляем вывод древовидного меню и вывод списка меток.
-categories_menu(@current_category)
%p
%h3 Метки
=render :partial => 'shared/tag_list'
Шаблон app/views/shared/_tag_list.html.haml
-Spending.tag_cloud.each do |tag|
%li
-unless @current_tag == tag
=link_to(tag.name, spendings_by_tag_path(tag)) + " (#{tag.spendings_count})"
-else
=tag.name + " (#{tag.spendings_count})"
Шаблон app/views/shared/_categories_menu.html.haml
%ul.sidebar_nav
-categories.each do |category|
-if category == @current_category
%li
=category.name
=render(:partial => "shared/categories_menu", :locals => {:categories => category.children, :path => path})
-else
%li
=link_to category.name, spendings_by_category_path(category)
=render(:partial => "shared/categories_menu", :locals => {:categories => category.children, :path => path}) if path.include?(category)
Помощник:
path = []
path = current.self_and_ancestors if current
concat(render(:partial => "shared/categories_menu", :locals => {:categories => Category.roots, :path => path}))
end
Теперь исправим функции добавления и редактирования трат. Добавим в них возможность указывать метки и изменим отображение категорий. С них и начнем. В модель Category добавим метод name_for_select
"#{"--" * self.depth}#{self.name}"
end
А в шаблоне поменяем вызов помощника select
И добавляем поля в шаблоне для ввода меток:
В контроллере
@spending = Spending.safe_build(params[:spending])
@spending.tag_list = params[:tags].split(',').collect(){|t| t.strip}
...
end
def update
@spending = Spending.find(params[:id])
respond_to do |format|
if @spending.safe_update_attributes(params[:spending])
@spending.tag_list = params[:tags].split(',').collect(){|t| t.strip }
flash[:notice] = 'Трата изменена.'
format.html { redirect_to(@spending) }
format.xml { head :ok }
else
format.html { render :action => "edit" }
format.xml { render :xml => @spending.errors, :status => :unprocessable_entity }
end
end
end

(3 голосов, средний: 4.00 из 5)