cldwalker-has_machine_tags 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE.txt +22 -0
- data/README.rdoc +134 -0
- data/generators/has_machine_tags_migration/has_machine_tags_migration_generator.rb +7 -0
- data/generators/has_machine_tags_migration/templates/migration.rb +26 -0
- data/lib/has_machine_tags/tag.rb +137 -0
- data/lib/has_machine_tags/tag_list.rb +15 -0
- data/lib/has_machine_tags/tagging.rb +3 -0
- data/lib/has_machine_tags.rb +125 -0
- data/test/has_machine_tags_test.rb +56 -0
- data/test/schema.rb +20 -0
- data/test/tag_test.rb +109 -0
- data/test/test_helper.rb +25 -0
- metadata +68 -0
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT LICENSE
|
2
|
+
|
3
|
+
Copyright (c) 2009 Gabriel Horner
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
== Description
|
2
|
+
|
3
|
+
This plugin implements Flickr's machine tags as explained
|
4
|
+
here[http://www.flickr.com/groups/api/discuss/72157594497877875]
|
5
|
+
while still maintaining standard tagging behavior.
|
6
|
+
|
7
|
+
Basically, a machine tag has a namespace, a predicate and a value in the format
|
8
|
+
[namespace]:[predicate]=[value]
|
9
|
+
|
10
|
+
This allows for more precise tagging as tags can have unlimited contexts provided
|
11
|
+
by combinations of namespaces and predicates. These unlimited contexts also make
|
12
|
+
machine tags ripe for modeling relationships between objects. Read the Tag class
|
13
|
+
documentation for a more thorough explanation.
|
14
|
+
|
15
|
+
== Install
|
16
|
+
|
17
|
+
Install as a gem
|
18
|
+
|
19
|
+
bash> gem install cldwalker-has_machine_tags -s http://gems.github.com
|
20
|
+
|
21
|
+
Or as a plugin
|
22
|
+
|
23
|
+
bash> script/plugin install git://github.com/cldwalker/has_machine_tags.git
|
24
|
+
|
25
|
+
Migrate your database from Rails root:
|
26
|
+
|
27
|
+
bash> script/generate has_machine_tags_migration
|
28
|
+
bash> rake db:migrate
|
29
|
+
|
30
|
+
== Usage
|
31
|
+
|
32
|
+
Setup a model to use has_machine_tags
|
33
|
+
|
34
|
+
class Url < ActiveRecord::Base
|
35
|
+
has_machine_tags
|
36
|
+
end
|
37
|
+
|
38
|
+
Let's create some urls with machine tags!
|
39
|
+
|
40
|
+
url = Url.create(:name=>"http://github.com/cldwalker/has_machine_tags",
|
41
|
+
:tag_list=>'gem:type=tagging,flickr')
|
42
|
+
|
43
|
+
url2 = Url.create(:name=>"http://github.com/giraffesoft/is_taggable",
|
44
|
+
:tag_list=>'gem:type=tagging, gem:user=giraffesoft')
|
45
|
+
|
46
|
+
url3 = Url.create(:name=>"http://github.com/datamapper/data_mapper/tree/master",
|
47
|
+
:tag_list=>'gem:type=orm')
|
48
|
+
|
49
|
+
url.tag_list # => ["gem:type=tagging", "flickr"]
|
50
|
+
|
51
|
+
url.tags # => [<Tag name:"gem:type=tagging">, <Tag name:"flickr">]
|
52
|
+
|
53
|
+
Let's query them:
|
54
|
+
|
55
|
+
# Query urls tagged as a gem having type tagging
|
56
|
+
Url.tagged_with 'gem:type=tagging' # => [url, url2] from above
|
57
|
+
|
58
|
+
# Non-machine tags work of course
|
59
|
+
Url.tagged_with 'flickr' # => [url] from above
|
60
|
+
|
61
|
+
# tagged_with() is a named_scope so do your sweet chaining
|
62
|
+
Url.tagged_with('flickr').yet_another_finder(:sweet).paginate(:per_page=>30)
|
63
|
+
|
64
|
+
Nothing interesting so far. We could've done the same with normal tagging.
|
65
|
+
|
66
|
+
But when we start with wildcard machine tag syntax, machine tags become more valuable:
|
67
|
+
|
68
|
+
# Query urls tagged as gems (namespace = 'gem')
|
69
|
+
Url.tagged_with 'gem:' # => [url, url2, url3] from above
|
70
|
+
|
71
|
+
# Query urls tagged as having a user, regardless of namespace and value (predicate = 'user')
|
72
|
+
Url.tagged_with 'user=' # => [url2] from above
|
73
|
+
|
74
|
+
# Query urls tagged as gems having a user ( namespace ='gem' AND predicate = 'user')
|
75
|
+
Url.tagged_with 'gem:user' # => [url2] from above
|
76
|
+
|
77
|
+
# Query urls tagged as having a tagging value, regardless of namespace and predicate (value = 'tagging')
|
78
|
+
Url.tagged_with '=tagging' # => [url, url2] from above
|
79
|
+
|
80
|
+
More details on machine tag syntax can be found in the Tag class.
|
81
|
+
|
82
|
+
=== More Usage
|
83
|
+
|
84
|
+
The wildcard machine tag syntax can also be used to fetch tags:
|
85
|
+
|
86
|
+
# Tags that are gems
|
87
|
+
Tag.machine_tags 'gem:' # => [<Tag name:"gem:type=tagging">, <Tag name:"gem:user=giraffesoft">]
|
88
|
+
|
89
|
+
# Tags that have a user predicate
|
90
|
+
Tag.machine_tags 'user=' # => [<Tag name:"gem:user=giraffesoft">]
|
91
|
+
|
92
|
+
Of course you can do the standard tag_list manipulation:
|
93
|
+
|
94
|
+
url.tag_list = "comma, delimited"
|
95
|
+
url.save
|
96
|
+
url.tag_list # =>['comma', 'delimited']
|
97
|
+
|
98
|
+
url.tag_list = ['or', 'an', 'array']
|
99
|
+
url.save
|
100
|
+
url.tag_list # =>['or', 'an' 'array']
|
101
|
+
|
102
|
+
#Add a tag
|
103
|
+
url.tag_list << 'another_tag'
|
104
|
+
url.save
|
105
|
+
url.tag_list # => ["gem:type=tagging", "flickr", "another_tag']
|
106
|
+
|
107
|
+
#Delete a tag
|
108
|
+
url.tag_list.delete('another_tag')
|
109
|
+
url.save
|
110
|
+
url.tag_list # => ["gem:type=tagging", "flickr"]
|
111
|
+
|
112
|
+
|
113
|
+
== Caveats
|
114
|
+
|
115
|
+
This is an experiment in progress so the api is subject to change.
|
116
|
+
|
117
|
+
Since machine tags require special characters to implement its goodness,
|
118
|
+
these characters are off limit unless used in the machine tag context:
|
119
|
+
|
120
|
+
'.', ':' , '*' , '=' , ','
|
121
|
+
|
122
|
+
== Todo
|
123
|
+
|
124
|
+
* More tests!
|
125
|
+
* Console methods for showing relations between namespaces, predicates + values
|
126
|
+
* Add support for DataMapper to be more ORM-agnostic
|
127
|
+
* Play friendly with other tagging plugins as needed.
|
128
|
+
|
129
|
+
== Credits
|
130
|
+
|
131
|
+
Thanks goes to Flickr for implementing this clever tagging model.
|
132
|
+
Thanks also goes to the {acts-as-taggable-on plugin}[http://github.com/mbleigh/acts-as-taggable-on/tree/master]
|
133
|
+
for their finder code and the {is_taggable plugin}[http://github.com/giraffesoft/is_taggable/tree/master]
|
134
|
+
for demonstrating sane testing for a Rails plugin.
|
@@ -0,0 +1,26 @@
|
|
1
|
+
class HasMachineTagsMigration < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :tags do |t|
|
4
|
+
t.string :name
|
5
|
+
t.string :namespace
|
6
|
+
t.string :predicate
|
7
|
+
t.string :value
|
8
|
+
t.datetime :created_at
|
9
|
+
end
|
10
|
+
|
11
|
+
create_table :taggings do |t|
|
12
|
+
t.integer :tag_id
|
13
|
+
t.integer :taggable_id
|
14
|
+
t.string :taggable_type
|
15
|
+
t.datetime :created_at
|
16
|
+
end
|
17
|
+
|
18
|
+
add_index :taggings, :tag_id
|
19
|
+
add_index :taggings, [:taggable_id, :taggable_type]
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.down
|
23
|
+
drop_table :taggings
|
24
|
+
drop_table :tags
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# == Machine Tags
|
2
|
+
# Machine tags are in the format:
|
3
|
+
# [namespace]:[predicate]=[value]
|
4
|
+
#
|
5
|
+
# As explained here[http://www.flickr.com/groups/api/discuss/72157594497877875],
|
6
|
+
# a namespace and predicate must start with a letter a-z while its remaining characters can be any lowercase alphanumeric character
|
7
|
+
# and underscore. A value can contain any characters that normal tags use.
|
8
|
+
#
|
9
|
+
# == Wildcard Machine Tags
|
10
|
+
# Wildcard machine tag syntax is used with Tag.machine_tags() and {tagged_with() or find_tagged_with()}[link:classes/HasMachineTags/SingletonMethods.html] of tagged objects.
|
11
|
+
# This syntax allows one to fetch items that fall under a group of tags, as specified by namespace, predicate, value or
|
12
|
+
# a combination of these ways. While this plugin supports {Flickr's wildcard format}[http://code.flickr.com/blog/2008/07/18/wildcard-machine-tag-urls/],
|
13
|
+
# it also supports its own slightly shorter format.
|
14
|
+
#
|
15
|
+
# === Examples
|
16
|
+
#
|
17
|
+
# For a tag 'user:name=john', the following wildcards would match it:
|
18
|
+
#
|
19
|
+
# * Wild namespace (any tag with namespace user)
|
20
|
+
# Tag.machine_tags 'user:' # Our way
|
21
|
+
# Tag.machine_tags 'user:*=' # Flickr way
|
22
|
+
#
|
23
|
+
# * Wild predicate (any tag with predicate name)
|
24
|
+
# Tag.machine_tags 'name=' # Our way
|
25
|
+
# Tag.machine_tags '*:name=' # Flickr way
|
26
|
+
#
|
27
|
+
# * Wild predicate (any tag with value john)
|
28
|
+
# Tag.machine_tags '=john' # Our way
|
29
|
+
# Tag.machine_tags '*:*=john' # Flickr way
|
30
|
+
#
|
31
|
+
# * Wild namespace and predicate (any tag with namespace user and predicate name)
|
32
|
+
# Tag.machine_tags 'user:name' # Our way
|
33
|
+
# Tag.machine_tags 'user:name=' # Flickr way
|
34
|
+
#
|
35
|
+
# * Wild predicate and value (any tag with predicate name and value john)
|
36
|
+
# Tag.machine_tags 'name=john' # Our way
|
37
|
+
# Tag.machine_tags '*:name=john' # Flickr way
|
38
|
+
#
|
39
|
+
# * Wild namespace and value (any tag with namespace user and value john)
|
40
|
+
# Tag.machine_tags 'user.john' # Our way
|
41
|
+
# Tag.machine_tags 'user:*=john' # Flickr way
|
42
|
+
#
|
43
|
+
# == Food For Thought
|
44
|
+
# So what's so great about being able to give a tag a namespace and a predicate?
|
45
|
+
# * It allows for more fine-grained tag queries by giving multiple contexts:
|
46
|
+
#
|
47
|
+
# Say instead of having tagged with 'user:name=john' we had tagged with the traditional separate
|
48
|
+
# tags: user, name and john. How would we know that we had meant an item to be tagged
|
49
|
+
# as a user ie with namespace user? We wouldn't know. Any query for 'user' would return
|
50
|
+
# all user-tagged items <b>without context</b>. With machine tags, we can have 'user' refer
|
51
|
+
# to a particular combination of namespace, predicate and value.
|
52
|
+
#
|
53
|
+
# * It keeps tag-spaces cleaner because there are more contexts:
|
54
|
+
#
|
55
|
+
# With traditional separate tags, tags just have a global context. So
|
56
|
+
# if different users decide to give different meaning to the same tag, the tag starts to become
|
57
|
+
# polluted and loses its usefulness. With the limitless contexts provided by machine tags,
|
58
|
+
# a machine tag is less likely to pollute other tags.
|
59
|
+
#
|
60
|
+
# * It allows tagging to serve as a medium for defining relationships between objects:
|
61
|
+
#
|
62
|
+
# Since a machine tag tracks three attributes (namespace, predicate and value), it's possible to develop relationships
|
63
|
+
# between the attributes. This means namespaces can have many predicates and predicates can have many values.
|
64
|
+
# Since this closely resembles object modeling, we can start to use tagging to form relationships between tagged items and other objects.
|
65
|
+
|
66
|
+
class Tag < ActiveRecord::Base
|
67
|
+
has_many :taggings
|
68
|
+
validates_presence_of :name
|
69
|
+
validates_uniqueness_of :name
|
70
|
+
before_save :update_name_related_columns
|
71
|
+
|
72
|
+
NAMESPACE_REGEX = "[a-z](?:[a-z0-9_]+)"
|
73
|
+
PREDICATE_REGEX = "[a-z](?:[a-z0-9_]+)"
|
74
|
+
VALUE_REGEX = '.+'
|
75
|
+
|
76
|
+
#disallow machine tags special characters and tag list delimiter OR allow machine tag format
|
77
|
+
validates_format_of :name, :with=>/\A(([^\*\=\:\.,]+)|(#{NAMESPACE_REGEX}\:#{PREDICATE_REGEX}\=#{VALUE_REGEX}))\Z/
|
78
|
+
|
79
|
+
named_scope :namespace_counts, :select=>'*, namespace as counter, count(namespace) as count', :group=>"namespace HAVING count(namespace)>=1"
|
80
|
+
named_scope :predicate_counts, :select=>'*, predicate as counter, count(predicate) as count', :group=>"predicate HAVING count(predicate)>=1"
|
81
|
+
named_scope :value_counts, :select=>'*, value as counter, count(value) as count', :group=>"value HAVING count(value)>=1"
|
82
|
+
named_scope :namespace, lambda {|namespace| {:conditions=>{:namespace=>namespace}} }
|
83
|
+
named_scope :predicate, lambda {|predicate| {:conditions=>{:predicate=>predicate}} }
|
84
|
+
named_scope :value, lambda {|value| {:conditions=>{:value=>value}} }
|
85
|
+
|
86
|
+
# To be used with the *counts methods.
|
87
|
+
# For example:
|
88
|
+
# stat(:namespace_counts)
|
89
|
+
# This prints out pairs of a namespaces and their counts in the tags table.
|
90
|
+
def self.stat(type)
|
91
|
+
send(type).map {|e| [e.counter, e.count] }
|
92
|
+
end
|
93
|
+
|
94
|
+
# Takes a wildcard machine tag and returns matching tags.
|
95
|
+
def self.machine_tags(name)
|
96
|
+
conditions = if (match = match_wildcard_machine_tag(name))
|
97
|
+
match.map {|k,v|
|
98
|
+
sanitize_sql(["#{k} = ?", v])
|
99
|
+
}.join(" AND ")
|
100
|
+
else
|
101
|
+
sanitize_sql(["name = ?", name])
|
102
|
+
end
|
103
|
+
find(:all, :conditions=>conditions)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Valid wildcards with their equivalent shortcuts
|
107
|
+
# namespace:*=* -> namespace:
|
108
|
+
# *:predicate=* -> predicate=
|
109
|
+
# *:*=value -> :value
|
110
|
+
def self.match_wildcard_machine_tag(name) #:nodoc:
|
111
|
+
if name =~ /^(#{NAMESPACE_REGEX}|\*)\:(#{PREDICATE_REGEX}|\*)\=(#{VALUE_REGEX}|\*)?$/
|
112
|
+
result = [[:namespace, $1], [:predicate, $2], [:value, $3]].select {|k,v| ![nil,'*'].include?(v) }
|
113
|
+
result.size == 3 ? nil : result
|
114
|
+
#duo shortcuts
|
115
|
+
elsif name =~ /^(#{NAMESPACE_REGEX}\:#{PREDICATE_REGEX})|(#{PREDICATE_REGEX}\=#{VALUE_REGEX})|(#{NAMESPACE_REGEX}\.#{VALUE_REGEX})$/
|
116
|
+
$1 ? [:namespace, :predicate].zip($1.split(":")) : ($2 ? [:predicate, :value].zip($2.split("=")) :
|
117
|
+
[:namespace, :value].zip($3.split('.')) )
|
118
|
+
#single shortcuts
|
119
|
+
elsif name =~ /^((#{NAMESPACE_REGEX})(?:\:)|(#{PREDICATE_REGEX})(?:\=)|(?:\=)(#{VALUE_REGEX}))$/
|
120
|
+
$2 ? [[:namespace, $2]] : ($3 ? [[:predicate, $3]] : [[:value, $4]])
|
121
|
+
else
|
122
|
+
nil
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def extract_from_name(tag_name) #:nodoc:
|
127
|
+
(tag_name =~ /^(#{NAMESPACE_REGEX})\:(#{PREDICATE_REGEX})\=(#{VALUE_REGEX})$/) ? [$1, $2, $3] : nil
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
def update_name_related_columns
|
133
|
+
if self.changed.include?('name') && (arr = extract_from_name(self.name))
|
134
|
+
self[:namespace], self[:predicate], self[:value] = arr
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module HasMachineTags
|
2
|
+
class TagList < Array #:nodoc:
|
3
|
+
cattr_accessor :delimiter
|
4
|
+
self.delimiter = ','
|
5
|
+
|
6
|
+
def initialize(string_or_array)
|
7
|
+
array = string_or_array.is_a?(Array) ? string_or_array : string_or_array.split(/\s*#{delimiter}\s*/)
|
8
|
+
concat array
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_s
|
12
|
+
join("#{delimiter} ")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
current_dir = File.dirname(__FILE__)
|
2
|
+
$:.unshift(current_dir) unless $:.include?(current_dir) || $:.include?(File.expand_path(current_dir))
|
3
|
+
require 'has_machine_tags/tag_list'
|
4
|
+
|
5
|
+
module HasMachineTags
|
6
|
+
def self.included(base) #:nodoc:
|
7
|
+
base.extend(ClassMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods #:nodoc:
|
11
|
+
def has_machine_tags
|
12
|
+
self.class_eval do
|
13
|
+
has_many :taggings, :as=>:taggable, :dependent=>:destroy
|
14
|
+
has_many :tags, :through=>:taggings
|
15
|
+
after_save :save_tags
|
16
|
+
|
17
|
+
include HasMachineTags::InstanceMethods
|
18
|
+
extend HasMachineTags::SingletonMethods
|
19
|
+
if respond_to?(:named_scope)
|
20
|
+
named_scope :tagged_with, lambda{ |tags, options|
|
21
|
+
find_options_for_find_tagged_with(tags, options)
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
module SingletonMethods
|
29
|
+
# Takes a string of delimited tags or an array of tags.
|
30
|
+
# Note that each tag is interpreted as a possible wildcard machine tag.
|
31
|
+
#
|
32
|
+
# Options:
|
33
|
+
# :exclude - Find models that are not tagged with the given tags
|
34
|
+
# :match_all - Find models that match all of the given tags, not just one, default: true
|
35
|
+
# :conditions - A piece of SQL conditions to add to the query
|
36
|
+
#
|
37
|
+
# Example:
|
38
|
+
# Url.tagged_with 'something' # => fetches urls tagged with 'something'
|
39
|
+
# Url.tagged_with 'gem:' # => fetches urls tagged with tags that have namespace gem
|
40
|
+
# Url.tagged_with 'gem:, something' # => fetches urls that are tagged with 'something'
|
41
|
+
# and tags that have namespace gem
|
42
|
+
#
|
43
|
+
# Note: This method really only needs to be used for Rails < 2.1 .
|
44
|
+
# Rails 2.1 and greater should use tagged_with(), which acts the same but with
|
45
|
+
# the benefits of named_scope.
|
46
|
+
#
|
47
|
+
def find_tagged_with(*args)
|
48
|
+
options = find_options_for_find_tagged_with(*args)
|
49
|
+
options.blank? ? [] : find(:all,options)
|
50
|
+
end
|
51
|
+
|
52
|
+
def find_options_for_find_tagged_with(tags, options = {}) #:nodoc:
|
53
|
+
options.reverse_merge!(:match_all=>true)
|
54
|
+
tags = TagList.new(tags)
|
55
|
+
return {} if tags.empty?
|
56
|
+
|
57
|
+
conditions = []
|
58
|
+
conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
|
59
|
+
|
60
|
+
taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
|
61
|
+
|
62
|
+
if options.delete(:exclude)
|
63
|
+
tags_conditions = tags.map { |t| sanitize_sql(["#{Tag.table_name}.name = ?", t]) }.join(" OR ")
|
64
|
+
conditions << sanitize_sql(["#{table_name}.id NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} LEFT OUTER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id WHERE (#{tags_conditions}) AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})", tags])
|
65
|
+
else
|
66
|
+
conditions << tags.map {|t|
|
67
|
+
if match = Tag.match_wildcard_machine_tag(t)
|
68
|
+
string = match.map {|k,v|
|
69
|
+
sanitize_sql(["#{tags_alias}.#{k} = ?", v])
|
70
|
+
}.join(" AND ")
|
71
|
+
"(#{string})"
|
72
|
+
else
|
73
|
+
sanitize_sql(["#{tags_alias}.name = ?", t])
|
74
|
+
end
|
75
|
+
}.join(" OR ")
|
76
|
+
|
77
|
+
if options.delete(:match_all)
|
78
|
+
group = "#{taggings_alias}.taggable_id HAVING COUNT(#{taggings_alias}.taggable_id) = #{tags.size}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
{ :select => "DISTINCT #{table_name}.*",
|
83
|
+
:joins => "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)} " +
|
84
|
+
"LEFT OUTER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id",
|
85
|
+
:conditions => conditions.join(" AND "),
|
86
|
+
:group => group
|
87
|
+
}.update(options)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
module InstanceMethods
|
92
|
+
# Set tag list with an array of tags or comma delimited string of tags
|
93
|
+
def tag_list=(list)
|
94
|
+
@tag_list = TagList.new(list)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Fetches latest tag list for an object
|
98
|
+
def tag_list
|
99
|
+
@tag_list ||= self.tags.map(&:name)
|
100
|
+
end
|
101
|
+
|
102
|
+
protected
|
103
|
+
# :stopdoc:
|
104
|
+
def save_tags
|
105
|
+
self.class.transaction do
|
106
|
+
delete_unused_tags
|
107
|
+
add_new_tags
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def delete_unused_tags
|
112
|
+
unused_tags = tags.select {|e| !tag_list.include?(e.name) }
|
113
|
+
tags.delete(*unused_tags)
|
114
|
+
end
|
115
|
+
|
116
|
+
def add_new_tags
|
117
|
+
new_tags = tag_list - (self.tags || []).map(&:name)
|
118
|
+
new_tags.each do |t|
|
119
|
+
self.tags << Tag.find_or_initialize_by_name(t)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
#:startdoc:
|
123
|
+
end
|
124
|
+
|
125
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
class HasMachineTagsTest < Test::Unit::TestCase
|
4
|
+
context "TagList" do
|
5
|
+
before(:each) { @taggable = TaggableModel.new }
|
6
|
+
|
7
|
+
test "sets tag list with array" do
|
8
|
+
arr = ['some', 'tag:name=blah']
|
9
|
+
@taggable.tag_list = arr
|
10
|
+
@taggable.tag_list.should == arr
|
11
|
+
end
|
12
|
+
|
13
|
+
test "sets tag list with delimited string" do
|
14
|
+
arr = ['more', 'tag:type=clever']
|
15
|
+
@taggable.tag_list = arr.join(", ")
|
16
|
+
@taggable.tag_list.should == arr
|
17
|
+
end
|
18
|
+
|
19
|
+
test "sets tag list with messy delimited string" do
|
20
|
+
arr = ['more', 'tag:type=dumb', 'really']
|
21
|
+
@taggable.tag_list = "more,tag:type=dumb, really"
|
22
|
+
@taggable.tag_list.should == arr
|
23
|
+
@taggable.tag_list.to_s.should == arr.join(", ")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
context "HasMachineTags" do
|
28
|
+
before(:each) { @taggable = TaggableModel.new }
|
29
|
+
|
30
|
+
test "creates all tags" do
|
31
|
+
tags = ['some', 'tag:name=blah']
|
32
|
+
@taggable.tag_list = tags
|
33
|
+
@taggable.save!
|
34
|
+
@taggable.tags.map(&:name).should == tags
|
35
|
+
end
|
36
|
+
|
37
|
+
test "only creates new tags" do
|
38
|
+
@taggable.tag_list = "bling"
|
39
|
+
@taggable.save!
|
40
|
+
tag_count = Tag.count
|
41
|
+
@taggable.tag_list = "bling, bling2"
|
42
|
+
@taggable.save!
|
43
|
+
@taggable.taggings.size.should == 2
|
44
|
+
Tag.count.should == tag_count + 1
|
45
|
+
end
|
46
|
+
|
47
|
+
test "deletes unused tags" do
|
48
|
+
@taggable.tag_list == 'bling, bling3'
|
49
|
+
@taggable.save!
|
50
|
+
@taggable.tag_list = "bling4"
|
51
|
+
@taggable.save!
|
52
|
+
@taggable.taggings.size.should == 1
|
53
|
+
@taggable.tags.map(&:name).should == ['bling4']
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/test/schema.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
ActiveRecord::Schema.define(:version => 0) do
|
2
|
+
create_table :taggable_models do |t|
|
3
|
+
t.string :title
|
4
|
+
end
|
5
|
+
|
6
|
+
create_table :tags do |t|
|
7
|
+
t.string :name
|
8
|
+
t.string :namespace
|
9
|
+
t.string :predicate
|
10
|
+
t.string :value
|
11
|
+
t.datetime :created_at
|
12
|
+
end
|
13
|
+
|
14
|
+
create_table :taggings do |t|
|
15
|
+
t.integer :tag_id
|
16
|
+
t.string :taggable_type
|
17
|
+
t.integer :taggable_id
|
18
|
+
t.datetime :created_at
|
19
|
+
end
|
20
|
+
end
|
data/test/tag_test.rb
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
class HasMachineTags::TagTest < Test::Unit::TestCase
|
4
|
+
test "create with normal tag name only touches name" do
|
5
|
+
obj = Tag.create(:name=>'blah1')
|
6
|
+
[:name, :namespace, :predicate, :value].map {|e| obj.send(e)}.should == ['blah1', nil, nil, nil]
|
7
|
+
end
|
8
|
+
|
9
|
+
test "create with machine tag name sets all name fields" do
|
10
|
+
obj = Tag.create(:name=>'gem:name=machine')
|
11
|
+
[:name, :namespace, :predicate, :value].map {|e| obj.send(e)}.should == ['gem:name=machine', 'gem', 'name', 'machine']
|
12
|
+
end
|
13
|
+
|
14
|
+
context "update" do
|
15
|
+
before(:each) { @obj = Tag.new }
|
16
|
+
|
17
|
+
test "with normal tag name only touches name" do
|
18
|
+
@obj.update_attributes :name=> 'bling'
|
19
|
+
[:name, :namespace, :predicate, :value].map {|e| @obj.send(e)}.should == ['bling', nil, nil, nil]
|
20
|
+
end
|
21
|
+
|
22
|
+
test "with machine tag name sets all name fields" do
|
23
|
+
@obj.update_attributes :name=>'gem:prop=value'
|
24
|
+
[:name, :namespace, :predicate, :value].map {|e| @obj.send(e)}.should == ['gem:prop=value', 'gem', 'prop', 'value']
|
25
|
+
end
|
26
|
+
|
27
|
+
test "with no name sets no name fields" do
|
28
|
+
@obj.update_attributes :name=>'blah2'
|
29
|
+
@obj.update_attributes :predicate=>'changed'
|
30
|
+
@obj.name.should == 'blah2'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context "match_wildcard_machine_tag" do
|
35
|
+
test "matches namespace with asterisk" do
|
36
|
+
Tag.match_wildcard_machine_tag('name:*=').should == [[:namespace,'name']]
|
37
|
+
end
|
38
|
+
|
39
|
+
test "matches namespace without asterisk" do
|
40
|
+
Tag.match_wildcard_machine_tag('name:').should == [[:namespace,'name']]
|
41
|
+
end
|
42
|
+
|
43
|
+
test "matches predicate with asterisk" do
|
44
|
+
Tag.match_wildcard_machine_tag('*:pred=').should == [[:predicate,'pred']]
|
45
|
+
end
|
46
|
+
|
47
|
+
test "matches predicate without asterisk" do
|
48
|
+
Tag.match_wildcard_machine_tag('pred=').should == [[:predicate,'pred']]
|
49
|
+
end
|
50
|
+
|
51
|
+
test "matches value with asterisk" do
|
52
|
+
Tag.match_wildcard_machine_tag('*:*=val').should == [[:value, 'val']]
|
53
|
+
end
|
54
|
+
|
55
|
+
test "matches value without asterisk" do
|
56
|
+
Tag.match_wildcard_machine_tag('=val').should == [[:value, 'val']]
|
57
|
+
end
|
58
|
+
|
59
|
+
test "matches namespace and predicate without asterisk" do
|
60
|
+
Tag.match_wildcard_machine_tag('name:pred').should == [[:namespace, 'name'], [:predicate, 'pred']]
|
61
|
+
end
|
62
|
+
|
63
|
+
test "matches namespace and predicate with asterisk" do
|
64
|
+
Tag.match_wildcard_machine_tag('name:pred=').should == [[:namespace, 'name'], [:predicate, 'pred']]
|
65
|
+
end
|
66
|
+
|
67
|
+
test "matches predicate and value without asterisk" do
|
68
|
+
Tag.match_wildcard_machine_tag('pred=val').should == [[:predicate, 'pred'], [:value, 'val']]
|
69
|
+
end
|
70
|
+
|
71
|
+
test "matches predicate and value with asterisk" do
|
72
|
+
Tag.match_wildcard_machine_tag('*:pred=val').should == [[:predicate, 'pred'], [:value, 'val']]
|
73
|
+
end
|
74
|
+
|
75
|
+
test "matches namespace and value without asterisk" do
|
76
|
+
Tag.match_wildcard_machine_tag('name.val').should == [[:namespace, 'name'], [:value, 'val']]
|
77
|
+
end
|
78
|
+
|
79
|
+
test "matches namespace and value with asterisk" do
|
80
|
+
Tag.match_wildcard_machine_tag('name:*=val').should == [[:namespace, 'name'], [:value, 'val']]
|
81
|
+
end
|
82
|
+
|
83
|
+
test "doesn't match total wildcard" do
|
84
|
+
Tag.match_wildcard_machine_tag('*:*=').should == []
|
85
|
+
end
|
86
|
+
|
87
|
+
test "doesn't match machine tag" do
|
88
|
+
Tag.match_wildcard_machine_tag('name:pred=val').should == nil
|
89
|
+
end
|
90
|
+
|
91
|
+
test "doesn't match normal tag" do
|
92
|
+
Tag.match_wildcard_machine_tag('name').should == nil
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
test "validates name when no invalid characters" do
|
97
|
+
Tag.new(:name=>'valid!name_really?').valid?.should be(true)
|
98
|
+
end
|
99
|
+
|
100
|
+
test "validates name when machine tag format" do
|
101
|
+
Tag.new(:name=>'name:pred=value').valid?.should be(true)
|
102
|
+
end
|
103
|
+
|
104
|
+
test "invalidates name when invalid characters present" do
|
105
|
+
%w{some.tag another:tag so=invalid yet,another whoop*}.each do |e|
|
106
|
+
Tag.new(:name=>e).valid?.should be(false)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'activerecord'
|
3
|
+
require 'test/unit'
|
4
|
+
require 'context' #gem install jeremymcanally-context -s http://gems.github.com
|
5
|
+
require 'matchy' #gem install jeremymcanally-matchy -s http://gems.github.com
|
6
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
7
|
+
require File.join(File.dirname(__FILE__), '..', 'init')
|
8
|
+
|
9
|
+
#Setup logger
|
10
|
+
require 'logger'
|
11
|
+
ActiveRecord::Base.logger = Logger.new(STDERR)
|
12
|
+
ActiveRecord::Base.logger.level = Logger::WARN
|
13
|
+
|
14
|
+
#Setup db
|
15
|
+
ActiveRecord::Base.configurations = {'sqlite3' => {:adapter => 'sqlite3', :database => ':memory:'}}
|
16
|
+
ActiveRecord::Base.establish_connection('sqlite3')
|
17
|
+
|
18
|
+
#Define schema
|
19
|
+
require File.join(File.dirname(__FILE__), 'schema')
|
20
|
+
class TaggableModel < ActiveRecord::Base
|
21
|
+
has_machine_tags
|
22
|
+
end
|
23
|
+
|
24
|
+
class Test::Unit::TestCase
|
25
|
+
end
|
metadata
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: cldwalker-has_machine_tags
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Gabriel Horner
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-02-12 00:00:00 -08:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: A rails tagging plugin implementing flickr's machine tags + maybe more (semantic tags)
|
17
|
+
email: gabriel.horner@gmail.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- README.rdoc
|
24
|
+
- LICENSE.txt
|
25
|
+
files:
|
26
|
+
- README.rdoc
|
27
|
+
- LICENSE.txt
|
28
|
+
- generators/has_machine_tags_migration
|
29
|
+
- generators/has_machine_tags_migration/has_machine_tags_migration_generator.rb
|
30
|
+
- generators/has_machine_tags_migration/templates
|
31
|
+
- generators/has_machine_tags_migration/templates/migration.rb
|
32
|
+
- lib/has_machine_tags
|
33
|
+
- lib/has_machine_tags/tag.rb
|
34
|
+
- lib/has_machine_tags/tag_list.rb
|
35
|
+
- lib/has_machine_tags/tagging.rb
|
36
|
+
- lib/has_machine_tags.rb
|
37
|
+
- test/has_machine_tags_test.rb
|
38
|
+
- test/schema.rb
|
39
|
+
- test/tag_test.rb
|
40
|
+
- test/test_helper.rb
|
41
|
+
has_rdoc: true
|
42
|
+
homepage: http://github.com/cldwalker/has_machine_tags
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options: []
|
45
|
+
|
46
|
+
require_paths:
|
47
|
+
- lib
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: "0"
|
53
|
+
version:
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: "0"
|
59
|
+
version:
|
60
|
+
requirements: []
|
61
|
+
|
62
|
+
rubyforge_project:
|
63
|
+
rubygems_version: 1.2.0
|
64
|
+
signing_key:
|
65
|
+
specification_version: 2
|
66
|
+
summary: A rails tagging plugin implementing flickr's machine tags + maybe more (semantic tags)
|
67
|
+
test_files: []
|
68
|
+
|