Undr

На память

Добавление меток и древовидных категорий в Rails приложение на MongoMapper

1 Star2 Stars3 Stars4 Stars5 Stars (3 голосов, средний: 4.00 из 5)
Loading ... Loading ...

without comments

Продолжение статьи Пример написания приложения на Rails с использованием MongoDB и MongoMapper вместо MySQL и ActiveRecord.

В приложении Shapado метки реализованы добавлением в модель поля tags с типом Array. Это поле содержит массив меток. В нашем случае:

class Spending
  ...
  key :tags, Array, :default => []
  ...
end

Поиск трат помеченных меткой осуществляется так:

  def self.by_tag(tag, conditions = {})
    self.find(:all, :conditions => conditions.merge({:tags => tag}))
  end

А список всех используемых меток, например для облака меток, так:

  def self.tag_cloud(conditions = {}, limit = 30)
    @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" лежит такой скрипт:

  function tagCloud(q, limit) {
    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, которое будет содержать количество трат помеченных меткой. Это поле будет обновляться при добавлении меток к трате или удалении траты.

class Tag
  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

Теперь выбрать траты помеченные меткой можно так:

  def self.by_tag_slug(tag_slug, conditions = {})
    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

А список всех меток, так:

  def self.tag_cloud(conditions = {}, limit = 30)
    Tag.find(:all, :conditions => conditions, :order => 'spendings_count desc', :limit => limit)
  end

Добавлять метки к трате можно так:

  @spending.tag_list = ['Бензин', 'ТНК', 'Автомобиль']

С метками разобрались. Теперь возьмемся за древовидные категории. Вообще разработчики 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

undr:spending undr$ gem install ramdiv-mongo_mapper_acts_as_tree

Или в файл config/environment.rb добавим gem

  config.gem 'ramdiv-mongo_mapper_acts_as_tree', :lib => 'mongo_mapper_acts_as_tree'

В консоли:

undr:spending undr$ rake gems:install
undr:spending undr$ rake gems:unpack

В модель Category добавляем:

class Category
  ...
  include MongoMapper::Acts::Tree
  acts_as_tree :order => 'name asc'
  ...
end

Теперь в нашем распоряжении модель обладающая методами необходимыми для работы с деревом.

Создание дерева

comm = Category.create(:name => "Связь")
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.roots
=> [#<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: []>
>>

Предки элемента

>> node.ancestors
=> [#<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]>]
>>

Дочерние элементы

>> root2 = Category.find_by_slug('otdyh')
=> #<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]>]
>>

И всевозможные проверки и свойства

>> root.is_ancestor_of?(node)
=> 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.

undr:spending undr$ rails -v
Rails 2.3.5

Теперь подправим контроллеры и шаблоны. Добавим реализацию метода tag в контроллере SpendingsController и изменим метод category так чтобы он добавлял в список траты принадлежащие дочерним категориям.

  def 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 добавляем вывод древовидного меню и вывод списка меток.

  %h3 По категориям
  -categories_menu(@current_category)
       
  %p &nbsp;

  %h3 Метки
  =render :partial => 'shared/tag_list'

Шаблон app/views/shared/_tag_list.html.haml

  %ul.sidebar_nav
    -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

  -unless categories.size.nil?
    %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)

Помощник:

  def categories_menu(current)
    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

  def name_for_select
    "#{"--" * self.depth}#{self.name}"
  end

А в шаблоне поменяем вызов помощника select

  =f.select :category_id, Category.find(:all).collect(){| c | [c.name_for_select, c._id] }

И добавляем поля в шаблоне для ввода меток:

  =text_field_tag 'tags', @spending.tag_list.collect(&:name).join(',')

В контроллере

  def create
    @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

Написал undr ()

12 января 2010 в 15:40

Оставьте комментарий