cldwalker-has_machine_tags 0.1.2
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.
- 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
|
+
|