directed-edge 0.1.0
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/.gitignore +3 -0
- data/LICENSE +22 -0
- data/README.rdoc +17 -0
- data/Rakefile +52 -0
- data/VERSION +1 -0
- data/directed-edge.gemspec +49 -0
- data/examples/example_store.rb +161 -0
- data/lib/directed_edge.rb +672 -0
- data/test/helper.rb +9 -0
- data/test/test_directed_edge.rb +334 -0
- metadata +73 -0
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (C) 2009, Directed Edge, Inc. <info@directededge.com>
|
2
|
+
|
3
|
+
Redistribution and use in source and binary forms, with or without
|
4
|
+
modification, are permitted provided that the following conditions
|
5
|
+
are met:
|
6
|
+
|
7
|
+
1. Redistributions of source code must retain the above copyright
|
8
|
+
notice, this list of conditions and the following disclaimer.
|
9
|
+
2. Redistributions in binary form must reproduce the above copyright
|
10
|
+
notice, this list of conditions and the following disclaimer in the
|
11
|
+
documentation and/or other materials provided with the distribution.
|
12
|
+
|
13
|
+
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
14
|
+
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
15
|
+
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
16
|
+
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
17
|
+
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
18
|
+
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
19
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
20
|
+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
21
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
22
|
+
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
= directed-edge
|
2
|
+
|
3
|
+
Bindings for the Directed Edge Web-services API
|
4
|
+
|
5
|
+
== Note on Patches/Pull Requests
|
6
|
+
|
7
|
+
* Fork the project.
|
8
|
+
* Make your feature addition or bug fix.
|
9
|
+
* Add tests for it. This is important so I don't break it in a
|
10
|
+
future version unintentionally.
|
11
|
+
* Commit, do not mess with rakefile, version, or history.
|
12
|
+
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
13
|
+
* Send me a pull request. Bonus points for topic branches.
|
14
|
+
|
15
|
+
== Copyright
|
16
|
+
|
17
|
+
Copyright (c) 2009 Directed Edge, Inc. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "directed-edge"
|
8
|
+
gem.summary = "Bindings for the Directed Edge webservices API"
|
9
|
+
gem.description = "Bindings for the Directed Edge webservices API"
|
10
|
+
gem.email = "info@directededge.com"
|
11
|
+
gem.homepage = "http://developer.directededge.com/"
|
12
|
+
gem.authors = ["Directed Edge"]
|
13
|
+
gem.add_dependency "rest-client", ">= 0"
|
14
|
+
end
|
15
|
+
Jeweler::GemcutterTasks.new
|
16
|
+
rescue LoadError
|
17
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
18
|
+
end
|
19
|
+
|
20
|
+
require 'rake/testtask'
|
21
|
+
Rake::TestTask.new(:test) do |test|
|
22
|
+
test.libs << 'lib' << 'test'
|
23
|
+
test.pattern = 'test/**/test_*.rb'
|
24
|
+
test.verbose = true
|
25
|
+
end
|
26
|
+
|
27
|
+
begin
|
28
|
+
require 'rcov/rcovtask'
|
29
|
+
Rcov::RcovTask.new do |test|
|
30
|
+
test.libs << 'test'
|
31
|
+
test.pattern = 'test/**/test_*.rb'
|
32
|
+
test.verbose = true
|
33
|
+
end
|
34
|
+
rescue LoadError
|
35
|
+
task :rcov do
|
36
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
task :test => :check_dependencies
|
41
|
+
|
42
|
+
task :default => :test
|
43
|
+
|
44
|
+
require 'rake/rdoctask'
|
45
|
+
Rake::RDocTask.new do |rdoc|
|
46
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
47
|
+
|
48
|
+
rdoc.rdoc_dir = 'rdoc'
|
49
|
+
rdoc.title = "directed-edge #{version}"
|
50
|
+
rdoc.rdoc_files.include('README*')
|
51
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
52
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.1.0
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{directed-edge}
|
8
|
+
s.version = "0.1.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Directed Edge"]
|
12
|
+
s.date = %q{2009-12-28}
|
13
|
+
s.description = %q{Bindings for the Directed Edge webservices API}
|
14
|
+
s.email = %q{info@directededge.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE"
|
17
|
+
]
|
18
|
+
s.files = [
|
19
|
+
".gitignore",
|
20
|
+
"LICENSE",
|
21
|
+
"README.rdoc",
|
22
|
+
"Rakefile",
|
23
|
+
"VERSION",
|
24
|
+
"directed-edge.gemspec",
|
25
|
+
"examples/example_store.rb",
|
26
|
+
"lib/directed_edge.rb",
|
27
|
+
"test/helper.rb",
|
28
|
+
"test/test_directed_edge.rb"
|
29
|
+
]
|
30
|
+
s.homepage = %q{http://developer.directededge.com/}
|
31
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
32
|
+
s.require_paths = ["lib"]
|
33
|
+
s.rubygems_version = %q{1.3.5}
|
34
|
+
s.summary = %q{Bindings for the Directed Edge webservices API}
|
35
|
+
|
36
|
+
if s.respond_to? :specification_version then
|
37
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
38
|
+
s.specification_version = 3
|
39
|
+
|
40
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
41
|
+
s.add_runtime_dependency(%q<rest-client>, [">= 0"])
|
42
|
+
else
|
43
|
+
s.add_dependency(%q<rest-client>, [">= 0"])
|
44
|
+
end
|
45
|
+
else
|
46
|
+
s.add_dependency(%q<rest-client>, [">= 0"])
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
@@ -0,0 +1,161 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'activerecord'
|
5
|
+
require 'directed_edge'
|
6
|
+
|
7
|
+
ActiveRecord::Base.establish_connection(:adapter => 'mysql',
|
8
|
+
:host => 'localhost',
|
9
|
+
:username => 'examplestore',
|
10
|
+
:password => 'password',
|
11
|
+
:database => 'examplestore')
|
12
|
+
|
13
|
+
class Customer < ActiveRecord::Base
|
14
|
+
end
|
15
|
+
|
16
|
+
class Product < ActiveRecord::Base
|
17
|
+
end
|
18
|
+
|
19
|
+
class Purchase < ActiveRecord::Base
|
20
|
+
end
|
21
|
+
|
22
|
+
class ExampleStore
|
23
|
+
def initialize
|
24
|
+
@database = DirectedEdge::Database.new('examplestore', 'password')
|
25
|
+
end
|
26
|
+
|
27
|
+
def export_from_mysql
|
28
|
+
|
29
|
+
# Use the handy Directed Edge XML exporter to collect store data up to this
|
30
|
+
# point
|
31
|
+
|
32
|
+
exporter = DirectedEdge::Exporter.new('examplestore.xml')
|
33
|
+
|
34
|
+
# Loop through every customer in the database
|
35
|
+
|
36
|
+
Customer.find(:all).each do |customer|
|
37
|
+
|
38
|
+
# Create a new item in the Directed Edge export file with the ID "customer12345"
|
39
|
+
|
40
|
+
item = DirectedEdge::Item.new(exporter.database, "customer#{customer.id}")
|
41
|
+
|
42
|
+
# Mark this item as a customer with a tag
|
43
|
+
|
44
|
+
item.add_tag('customer')
|
45
|
+
|
46
|
+
# Find all of the purchases for the current customer
|
47
|
+
|
48
|
+
purchases = Purchase.find(:all, :conditions => { :customer => customer.id })
|
49
|
+
|
50
|
+
# For each purchase create a link from the customer to that item of the form
|
51
|
+
# "product12345"
|
52
|
+
|
53
|
+
purchases.each { |purchase| item.link_to("product#{purchase.product}") }
|
54
|
+
|
55
|
+
# And now write the item to the export file
|
56
|
+
|
57
|
+
exporter.export(item)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Now go through all of the products creating items for them
|
61
|
+
|
62
|
+
Product.find(:all).each do |product|
|
63
|
+
|
64
|
+
# Here we'll also use the form "product12345" for our products
|
65
|
+
|
66
|
+
item = DirectedEdge::Item.new(exporter.database, "product#{product.id}")
|
67
|
+
|
68
|
+
# And mark it as a product with a tag
|
69
|
+
|
70
|
+
item.add_tag('product')
|
71
|
+
|
72
|
+
# And export it to the file
|
73
|
+
|
74
|
+
exporter.export(item)
|
75
|
+
end
|
76
|
+
|
77
|
+
# We have to tell the exporter to clean up and finish up the file
|
78
|
+
|
79
|
+
exporter.finish
|
80
|
+
end
|
81
|
+
|
82
|
+
# Imports the file exported from the export method to the Directed Edge database
|
83
|
+
|
84
|
+
def import_to_directededge
|
85
|
+
@database.import('examplestore.xml')
|
86
|
+
end
|
87
|
+
|
88
|
+
# Creates a new customer in the Directed Edge database that corresponds to the
|
89
|
+
# given customer ID
|
90
|
+
|
91
|
+
def create_customer(id)
|
92
|
+
item = DirectedEdge::Item.new(@database, "customer#{id}")
|
93
|
+
item.add_tag('customer')
|
94
|
+
item.save
|
95
|
+
end
|
96
|
+
|
97
|
+
# Creates a new product in the Directed Edge database that corresponds to the
|
98
|
+
# given product ID
|
99
|
+
|
100
|
+
def create_product(id)
|
101
|
+
item = DirectedEdge::Item.new(@database, "product#{id}")
|
102
|
+
item.add_tag('product')
|
103
|
+
item.save
|
104
|
+
end
|
105
|
+
|
106
|
+
# Notes in the Directed Edge database that customer_id purchased product_id
|
107
|
+
|
108
|
+
def add_purchase(customer_id, product_id)
|
109
|
+
item = DirectedEdge::Item.new(@database, "customer#{customer_id}")
|
110
|
+
item.link_to("product#{product_id}")
|
111
|
+
item.save
|
112
|
+
end
|
113
|
+
|
114
|
+
# Returns a list of product IDs related to the given product ID
|
115
|
+
|
116
|
+
def related_products(product_id)
|
117
|
+
item = DirectedEdge::Item.new(@database, "product#{product_id}")
|
118
|
+
item.related(['product']).map { |product| product.sub('product', '').to_i }
|
119
|
+
end
|
120
|
+
|
121
|
+
# Returns a list of personalized recommendations for the given customer ID
|
122
|
+
|
123
|
+
def personalized_recommendations(customer_id)
|
124
|
+
item = DirectedEdge::Item.new(@database, "customer#{customer_id}")
|
125
|
+
item.recommended(['product']).map { |product| product.sub('product', '').to_i }
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
store = ExampleStore.new
|
130
|
+
|
131
|
+
# Export the contents of our MySQL database to XML
|
132
|
+
|
133
|
+
store.export_from_mysql
|
134
|
+
|
135
|
+
# Import that XML to the Directed Edge database
|
136
|
+
|
137
|
+
store.import_to_directededge
|
138
|
+
|
139
|
+
# Add a new customer
|
140
|
+
|
141
|
+
store.create_customer(500)
|
142
|
+
|
143
|
+
# Add a new product
|
144
|
+
|
145
|
+
store.create_product(2000)
|
146
|
+
|
147
|
+
# Set that user as having purchased that product
|
148
|
+
|
149
|
+
store.add_purchase(500, 2000)
|
150
|
+
|
151
|
+
# Find related products for the product with the ID 1 (in MySQL)
|
152
|
+
|
153
|
+
store.related_products(1).each do |product|
|
154
|
+
puts "Related products for product 1: #{product}"
|
155
|
+
end
|
156
|
+
|
157
|
+
# Find personalized recommendations for the user with the ID 1 (in MySQL)
|
158
|
+
|
159
|
+
store.personalized_recommendations(1).each do |product|
|
160
|
+
puts "Personalized recommendations for user 1: #{product}"
|
161
|
+
end
|
@@ -0,0 +1,672 @@
|
|
1
|
+
# Copyright (C) 2009 Directed Edge, Inc.
|
2
|
+
#
|
3
|
+
# Redistribution and use in source and binary forms, with or without
|
4
|
+
# modification, are permitted provided that the following conditions
|
5
|
+
# are met:
|
6
|
+
#
|
7
|
+
# 1. Redistributions of source code must retain the above copyright
|
8
|
+
# notice, this list of conditions and the following disclaimer.
|
9
|
+
# 2. Redistributions in binary form must reproduce the above copyright
|
10
|
+
# notice, this list of conditions and the following disclaimer in the
|
11
|
+
# documentation and/or other materials provided with the distribution.
|
12
|
+
#
|
13
|
+
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
14
|
+
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
15
|
+
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
16
|
+
# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
17
|
+
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
18
|
+
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
19
|
+
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
20
|
+
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
21
|
+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
22
|
+
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
23
|
+
|
24
|
+
require 'rubygems'
|
25
|
+
require 'rest_client'
|
26
|
+
require 'rexml/document'
|
27
|
+
require 'cgi'
|
28
|
+
|
29
|
+
class Hash
|
30
|
+
|
31
|
+
# An extension to normalize tokens and strings of the form foo_bar to strings
|
32
|
+
# of fooBar as expected by the REST API.
|
33
|
+
|
34
|
+
def normalize!
|
35
|
+
each do |key, value|
|
36
|
+
if !key.is_a?(String)
|
37
|
+
delete(key)
|
38
|
+
key = key.to_s
|
39
|
+
store(key, value.to_s)
|
40
|
+
end
|
41
|
+
if key.match(/_\w/)
|
42
|
+
delete(key)
|
43
|
+
store(key.gsub(/_\w/) { |s| s[1, 1].upcase }, value.to_s)
|
44
|
+
elsif !value.is_a?(String)
|
45
|
+
store(key, value.to_s)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
self
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# The DirectedEdge module contains three classes:
|
53
|
+
#
|
54
|
+
# - Database - encapsulation of connection a database hosted by Directed Edge.
|
55
|
+
# - Exporter - simple mechanism for exporting data from existing data sources.
|
56
|
+
# - Item - item (user, product, page) in a Directed Edge database.
|
57
|
+
|
58
|
+
module DirectedEdge
|
59
|
+
|
60
|
+
# Base class used for Database and Item that has some basic resource
|
61
|
+
# grabbing functionality.
|
62
|
+
|
63
|
+
class Resource
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# Reads an item from the database and puts it into an XML document.
|
68
|
+
|
69
|
+
def read_document(method='', params={})
|
70
|
+
method << '?' << params.map { |key, value| "#{URI.encode(key)}=#{URI.encode(value)}" }.join('&')
|
71
|
+
REXML::Document.new(@resource[method].get(:accept => 'text/xml'))
|
72
|
+
end
|
73
|
+
|
74
|
+
# Returns an array of the elements from the document matching the given
|
75
|
+
# element name.
|
76
|
+
|
77
|
+
def list_from_document(document, element)
|
78
|
+
values = []
|
79
|
+
document.elements.each("//#{element}") { |v| values.push(v.text) }
|
80
|
+
values
|
81
|
+
end
|
82
|
+
|
83
|
+
# Similar to list_from_document, but instead of a list of items for the given
|
84
|
+
# element returns a hash of key-value pairs (attributes), e.g.:
|
85
|
+
#
|
86
|
+
# 'item1' => { 'foo' => 'bar' }
|
87
|
+
|
88
|
+
def property_hash_from_document(document, element)
|
89
|
+
values = {}
|
90
|
+
document.elements.each("//#{element}") do |e|
|
91
|
+
values[e.text] = {}
|
92
|
+
e.attributes.each_attribute { |a| values[e.text][a.name] = a.value }
|
93
|
+
end
|
94
|
+
values
|
95
|
+
end
|
96
|
+
|
97
|
+
# Returns a hash of the elements from the document matching the given
|
98
|
+
# element name. If the specified attribute is present, its value will
|
99
|
+
# be assigned to the hash, otherwise the default value given will be
|
100
|
+
# used.
|
101
|
+
|
102
|
+
def hash_from_document(document, element, attribute, default=0)
|
103
|
+
values = {}
|
104
|
+
document.elements.each("//#{element}") do |v|
|
105
|
+
value = v.attribute(attribute).to_s || default
|
106
|
+
if value.empty?
|
107
|
+
values[v.text] = default
|
108
|
+
elsif value.to_i.to_s == value.to_s
|
109
|
+
values[v.text] = value.to_i
|
110
|
+
else
|
111
|
+
values[v.text] = value.to_s
|
112
|
+
end
|
113
|
+
end
|
114
|
+
values
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# A Database is an encapsulation of a database being accessed via the Directed
|
119
|
+
# Edge web-services API. You can request database creation by visiting
|
120
|
+
# http://www.directededge.com and will recieve a user name and password which
|
121
|
+
# are then used to connect to your DirectedEdge::Database instance.
|
122
|
+
#
|
123
|
+
# Usually when getting started with a DirectedEdge database, users would like to
|
124
|
+
# import some pre-existing data, usually from their web application's database.
|
125
|
+
# The Database class has an import method which can be used to import data using
|
126
|
+
# Directed Edge's XML format. Files formatted in that way may be created with
|
127
|
+
# the Exporter.
|
128
|
+
#
|
129
|
+
# A database is typically instantiated via:
|
130
|
+
#
|
131
|
+
# database = DirectedEdge::Database.new('mydatabase', 'mypassword')
|
132
|
+
|
133
|
+
class Database < Resource
|
134
|
+
|
135
|
+
# The name of the database.
|
136
|
+
|
137
|
+
attr_reader :name
|
138
|
+
|
139
|
+
# The REST resource used for connecting to the database.
|
140
|
+
|
141
|
+
attr_reader :resource
|
142
|
+
|
143
|
+
# Creates a connection to a Directed Edge database. The name and password
|
144
|
+
# should have been provided when the account was created. The protocol
|
145
|
+
# parameter is optional and may be <tt>http</tt> or <tt>https</tt>.
|
146
|
+
# <tt>http</tt> is used by default as it is somewhat lower latency.
|
147
|
+
|
148
|
+
def initialize(name, password='', protocol='http')
|
149
|
+
@name = name
|
150
|
+
host = ENV['DIRECTEDEDGE_HOST'] || 'webservices.directededge.com'
|
151
|
+
@resource =
|
152
|
+
RestClient::Resource.new("#{protocol}://#{name}:#{password}@#{host}/api/v1/#{name}")
|
153
|
+
end
|
154
|
+
|
155
|
+
# Imports a Directed Edge XML file to the database.
|
156
|
+
#
|
157
|
+
# See http://developer.directededge.com for more information on the XML format or the
|
158
|
+
# Exporter for help on creating a file for importing.
|
159
|
+
|
160
|
+
def import(file_name)
|
161
|
+
@resource.put(File.read(file_name), :content_type => 'text/xml')
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns a set of recommendations for the set of items that is passed in in
|
165
|
+
# aggregate, commonly used to do recommendations for a basket of items.
|
166
|
+
|
167
|
+
def group_related(items=Set.new, tags=Set.new, params={})
|
168
|
+
(!items.is_a?(Array) || items.size < 1) and return []
|
169
|
+
params['items'] = items.to_a.join(',')
|
170
|
+
params['tags'] = tags.to_a.join(',')
|
171
|
+
params['union'] = true
|
172
|
+
params.normalize!
|
173
|
+
if params['includeProperties'] == 'true'
|
174
|
+
property_hash_from_document(read_document('related', params), 'related')
|
175
|
+
else
|
176
|
+
list_from_document(read_document('related', params), 'related')
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# A very simple class for creating Directed Edge XML files or doing batch
|
182
|
+
# updates to a database. This can be done for example with:
|
183
|
+
#
|
184
|
+
# exporter = DirectedEdge::Exporter.new('mydatabase.xml')
|
185
|
+
# item = DirectedEdge::Item.new(exporter.database, 'product_1')
|
186
|
+
# item.add_tag('product')
|
187
|
+
# exporter.export(item)
|
188
|
+
# exporter.finish
|
189
|
+
#
|
190
|
+
# <tt>mydatabase.xml</tt> now contains:
|
191
|
+
#
|
192
|
+
# <?xml version="1.0" encoding="UTF-8"?>
|
193
|
+
# <directededge version="0.1">
|
194
|
+
# <item id='product_1'><tag>product</tag></item>
|
195
|
+
# </directededge>
|
196
|
+
#
|
197
|
+
# Which can then be imported to a database on the server with:
|
198
|
+
#
|
199
|
+
# database = DirectedEdge::Database.new('mydatabase', 'mypassword')
|
200
|
+
# database.import('mydatabase.xml')
|
201
|
+
#
|
202
|
+
# Alternatively, had the first line been:
|
203
|
+
#
|
204
|
+
# exporter = DirectedEdge::Exporter.new(some_database_object)
|
205
|
+
#
|
206
|
+
# Then newly created / modfied objects that on which export was called would be
|
207
|
+
# queued for a batch update to the database later.
|
208
|
+
#
|
209
|
+
# Items may also be exported from existing databases.
|
210
|
+
|
211
|
+
class Exporter
|
212
|
+
|
213
|
+
# Provides a dummy database for use when creating new items to be exported.
|
214
|
+
|
215
|
+
attr_reader :database
|
216
|
+
|
217
|
+
# Begins exporting a collection of items to the given destination. If the
|
218
|
+
# destination is a file existing contents will be overwritten. If the
|
219
|
+
# destination is an existing database object, updates will be queued until
|
220
|
+
# finish is called, at which point they will be uploaded to the webservices
|
221
|
+
# in batch.
|
222
|
+
|
223
|
+
def initialize(destination)
|
224
|
+
if destination.is_a?(String)
|
225
|
+
@database = Database.new('exporter')
|
226
|
+
@file = File.new(destination, 'w')
|
227
|
+
elsif destination.is_a?(Database)
|
228
|
+
@database = destination
|
229
|
+
@data = ""
|
230
|
+
else
|
231
|
+
raise TypeError.new("Exporter must be passed a file name or database object.")
|
232
|
+
end
|
233
|
+
|
234
|
+
write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n")
|
235
|
+
write("<directededge version=\"0.1\">\n")
|
236
|
+
end
|
237
|
+
|
238
|
+
# Exports the given item to the file passed to the constructor.
|
239
|
+
|
240
|
+
def export(item)
|
241
|
+
write("#{item.to_xml}\n")
|
242
|
+
end
|
243
|
+
|
244
|
+
# Writes a closing XML element to the document and closes the file.
|
245
|
+
|
246
|
+
def finish
|
247
|
+
write("</directededge>\n")
|
248
|
+
if !@file.nil?
|
249
|
+
@file.close
|
250
|
+
else
|
251
|
+
@database.resource['add'].put(@data)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
private
|
256
|
+
|
257
|
+
def write(data)
|
258
|
+
if !@file.nil?
|
259
|
+
@file.write(data)
|
260
|
+
else
|
261
|
+
@data += data
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Represents an item in a Directed Edge database. Items can be products, pages
|
267
|
+
# or users, for instance. Usually items groups are differentiated from one
|
268
|
+
# another by a set of tags that are provided.
|
269
|
+
#
|
270
|
+
# For instance, a user in the Directed Edge database could be modeled as:
|
271
|
+
#
|
272
|
+
# user = DirectedEdge::Item.new(database, 'user_1')
|
273
|
+
# user.add_tag('user')
|
274
|
+
# user.save
|
275
|
+
#
|
276
|
+
# Similarly a product could be:
|
277
|
+
#
|
278
|
+
# product = DirectedEdge::Item.new(database, 'product_1')
|
279
|
+
# product.add_tag('product')
|
280
|
+
# product['price'] = '$42'
|
281
|
+
# product.save
|
282
|
+
#
|
283
|
+
# Note here that items have tags and properties. Tags are a free-form set of
|
284
|
+
# text identifiers that can be associated with an item, e.g. "user", "product",
|
285
|
+
# "page", "science fiction", etc.
|
286
|
+
#
|
287
|
+
# Properties are a set of key-value pairs associated with the item. For example,
|
288
|
+
# <tt>product['price'] = '$42'</tt>, or <tt>user['first name'] = 'Bob'</tt>.
|
289
|
+
#
|
290
|
+
# If we wanted to link the user to the product, for instance, indicating that the
|
291
|
+
# user had purchased the product we can use:
|
292
|
+
#
|
293
|
+
# user.link_to(product)
|
294
|
+
# user.save
|
295
|
+
|
296
|
+
class Item < Resource
|
297
|
+
|
298
|
+
# The unique item identifier used by the database and specified in the item's
|
299
|
+
# constructor.
|
300
|
+
|
301
|
+
attr_reader :id
|
302
|
+
|
303
|
+
# Creates a handle to an item in the DirectedEdge database which may be
|
304
|
+
# manipulated locally and then saved back to the database by calling save.
|
305
|
+
|
306
|
+
def initialize(database, id)
|
307
|
+
@database = database
|
308
|
+
|
309
|
+
@id = id
|
310
|
+
@links = {}
|
311
|
+
@tags = Set.new
|
312
|
+
@properties = {}
|
313
|
+
|
314
|
+
@links_to_remove = Set.new
|
315
|
+
@tags_to_remove = Set.new
|
316
|
+
@properties_to_remove = Set.new
|
317
|
+
|
318
|
+
@resource = @database.resource[URI.escape(@id)]
|
319
|
+
@cached = false
|
320
|
+
end
|
321
|
+
|
322
|
+
# Returns true if the other item is the same. The item given can either be
|
323
|
+
# a string or an item object.
|
324
|
+
|
325
|
+
def ==(other)
|
326
|
+
if other.is_a?(Item)
|
327
|
+
other.id == id
|
328
|
+
else
|
329
|
+
other.to_s == id
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
# Returns the item's ID.
|
334
|
+
|
335
|
+
def name
|
336
|
+
@id
|
337
|
+
end
|
338
|
+
|
339
|
+
# Creates an item if it does not already exist in the database or overwrites
|
340
|
+
# an existing item if one does.
|
341
|
+
|
342
|
+
def create(links={}, tags=Set.new, properties={})
|
343
|
+
@links = links
|
344
|
+
@tags = tags
|
345
|
+
@properties = properties
|
346
|
+
|
347
|
+
# Here we pretend that it's cached since this is now the authoritative
|
348
|
+
# copy of the values.
|
349
|
+
|
350
|
+
@cached = true
|
351
|
+
save
|
352
|
+
end
|
353
|
+
|
354
|
+
# Writes all changes to links, tags and properties back to the database and
|
355
|
+
# returns this item.
|
356
|
+
|
357
|
+
def save
|
358
|
+
if @cached
|
359
|
+
put(complete_document)
|
360
|
+
else
|
361
|
+
|
362
|
+
# The web services API allows to add or remove things incrementally.
|
363
|
+
# Since we're not in the cached case, let's check to see which action(s)
|
364
|
+
# are appropriate.
|
365
|
+
|
366
|
+
put(complete_document, 'add')
|
367
|
+
|
368
|
+
if !@links_to_remove.empty? || !@tags_to_remove.empty? || !@properties_to_remove.empty?
|
369
|
+
put(removal_document, 'remove')
|
370
|
+
@links_to_remove.clear
|
371
|
+
@tags_to_remove.clear
|
372
|
+
@properties_to_remove.clear
|
373
|
+
end
|
374
|
+
end
|
375
|
+
self
|
376
|
+
end
|
377
|
+
|
378
|
+
# Reloads (or loads) the item from the database. Any unsaved changes will
|
379
|
+
# will be discarded.
|
380
|
+
|
381
|
+
def reload
|
382
|
+
document = read_document
|
383
|
+
|
384
|
+
@links = hash_from_document(document, 'link', 'weight')
|
385
|
+
@tags = Set.new(list_from_document(document, 'tag'))
|
386
|
+
@properties = {}
|
387
|
+
|
388
|
+
@links_to_remove.clear
|
389
|
+
@tags_to_remove.clear
|
390
|
+
@properties_to_remove.clear
|
391
|
+
|
392
|
+
document.elements.each('//property') do |element|
|
393
|
+
@properties[element.attribute('name').value] = element.text
|
394
|
+
end
|
395
|
+
@cached = true
|
396
|
+
end
|
397
|
+
|
398
|
+
# Returns a set of items that are linked to from this item.
|
399
|
+
|
400
|
+
def links
|
401
|
+
read
|
402
|
+
@links
|
403
|
+
end
|
404
|
+
|
405
|
+
# Returns a set containing all of this item's tags.
|
406
|
+
|
407
|
+
def tags
|
408
|
+
read
|
409
|
+
@tags
|
410
|
+
end
|
411
|
+
|
412
|
+
# Returns a hash of all of this item's properties.
|
413
|
+
|
414
|
+
def properties
|
415
|
+
read
|
416
|
+
@properties
|
417
|
+
end
|
418
|
+
|
419
|
+
# Returns the property for the name specified.
|
420
|
+
|
421
|
+
def [](property_name)
|
422
|
+
read
|
423
|
+
@properties[property_name]
|
424
|
+
end
|
425
|
+
|
426
|
+
# Assigns value to the given property_name.
|
427
|
+
#
|
428
|
+
# This will not be written back to the database until save is called.
|
429
|
+
|
430
|
+
def []=(property_name, value)
|
431
|
+
@properties_to_remove.delete(property_name)
|
432
|
+
@properties[property_name] = value
|
433
|
+
end
|
434
|
+
|
435
|
+
# Remove the given property_name.
|
436
|
+
|
437
|
+
def clear_property(property_name)
|
438
|
+
if !@cached
|
439
|
+
@properties_to_remove.add(property_name)
|
440
|
+
end
|
441
|
+
@properties.delete(property_name)
|
442
|
+
end
|
443
|
+
|
444
|
+
# Removes an item from the database, including deleting all links to and
|
445
|
+
# from this item.
|
446
|
+
|
447
|
+
def destroy
|
448
|
+
@resource.delete
|
449
|
+
end
|
450
|
+
|
451
|
+
# Creates a link from this item to other.
|
452
|
+
#
|
453
|
+
# Weighted links are typically used to encode ratings. For instance, if
|
454
|
+
# a user has rated a given product that can be specified via:
|
455
|
+
#
|
456
|
+
# user = DirectedEdge::Item(database, 'user_1')
|
457
|
+
# product = DirectedEdge::Item(database, 'product_1') # preexisting item
|
458
|
+
# user.link_to(product, 5)
|
459
|
+
# user.save
|
460
|
+
#
|
461
|
+
# If no link is specified then a tradtional, unweighted link will be
|
462
|
+
# created. This is typical to, for instance, incidate a purchase or click
|
463
|
+
# from a user to a page or item.
|
464
|
+
#
|
465
|
+
# Weights may be in the range of 1 to 10.
|
466
|
+
#
|
467
|
+
# Note that 'other' must exist in the database or must be saved before this
|
468
|
+
# item is saved. Otherwise the link will be ignored as the engine tries
|
469
|
+
# to detect 'broken' links that do not terminate at a valid item.
|
470
|
+
|
471
|
+
def link_to(other, weight=0)
|
472
|
+
if weight < 0 || weight > 10
|
473
|
+
raise RangeError
|
474
|
+
end
|
475
|
+
@links_to_remove.delete(other)
|
476
|
+
@links[other.to_s] = weight
|
477
|
+
end
|
478
|
+
|
479
|
+
# Deletes a link from this item to other.
|
480
|
+
#
|
481
|
+
# The changes will not be reflected in the database until save is called.
|
482
|
+
|
483
|
+
def unlink_from(other)
|
484
|
+
if !@cached
|
485
|
+
@links_to_remove.add(other.to_s)
|
486
|
+
end
|
487
|
+
@links.delete(other.to_s)
|
488
|
+
end
|
489
|
+
|
490
|
+
# If there is a link for "other" then it returns the weight for the given
|
491
|
+
# item. Zero indicates that no weight is assigned.
|
492
|
+
|
493
|
+
def weight_for(other)
|
494
|
+
read
|
495
|
+
@links[other.to_s]
|
496
|
+
end
|
497
|
+
|
498
|
+
# Adds a tag to this item.
|
499
|
+
#
|
500
|
+
# The changes will not be reflected in the database until save is called.
|
501
|
+
|
502
|
+
def add_tag(tag)
|
503
|
+
@tags_to_remove.delete(tag)
|
504
|
+
@tags.add(tag)
|
505
|
+
end
|
506
|
+
|
507
|
+
# Removes a tag from this item.
|
508
|
+
#
|
509
|
+
# The changes will not be reflected in the database until save is called.
|
510
|
+
|
511
|
+
def remove_tag(tag)
|
512
|
+
if !@cached
|
513
|
+
@tags_to_remove.add(tag)
|
514
|
+
end
|
515
|
+
@tags.delete(tag)
|
516
|
+
end
|
517
|
+
|
518
|
+
# Returns the list of items related to this one. Unlike "recommended" this
|
519
|
+
# may include items which are directly linked from this item. If any tags
|
520
|
+
# are specified, only items which have one or more of the specified tags
|
521
|
+
# will be returned.
|
522
|
+
#
|
523
|
+
# Parameters that may be passed in include:
|
524
|
+
# - :exclude_linked (true / false)
|
525
|
+
# - :max_results (integer)
|
526
|
+
#
|
527
|
+
# This will not reflect any unsaved changes to items.
|
528
|
+
|
529
|
+
def related(tags=Set.new, params={})
|
530
|
+
params.normalize!
|
531
|
+
params['tags'] = tags.to_a.join(',')
|
532
|
+
if params['includeProperties'] == 'true'
|
533
|
+
property_hash_from_document(read_document('related', params), 'related')
|
534
|
+
else
|
535
|
+
list_from_document(read_document('related', params), 'related')
|
536
|
+
end
|
537
|
+
end
|
538
|
+
|
539
|
+
# Returns the list of items recommended for this item, usually a user.
|
540
|
+
# Unlike "related" this does not include items linked from this item. If
|
541
|
+
# any tags are specified, only items which have one or more of the specified
|
542
|
+
# tags will be returned.
|
543
|
+
#
|
544
|
+
# Parameters that may be passed in include:
|
545
|
+
# - :exclude_linked (true / false)
|
546
|
+
# - :max_results (integer)
|
547
|
+
#
|
548
|
+
# This will not reflect any unsaved changes to items.
|
549
|
+
|
550
|
+
def recommended(tags=Set.new, params={})
|
551
|
+
params.normalize!
|
552
|
+
params['tags'] = tags.to_a.join(',')
|
553
|
+
params.key?('excludeLinked') || params['excludeLinked'] = 'true'
|
554
|
+
if params['includeProperties'] == 'true'
|
555
|
+
property_hash_from_document(read_document('recommended', params), 'recommended')
|
556
|
+
else
|
557
|
+
list_from_document(read_document('recommended', params), 'recommended')
|
558
|
+
end
|
559
|
+
end
|
560
|
+
|
561
|
+
# Returns the ID of the item.
|
562
|
+
|
563
|
+
def to_s
|
564
|
+
@id
|
565
|
+
end
|
566
|
+
|
567
|
+
# Returns an XML representation of the item as a string not including the
|
568
|
+
# usual document regalia, e.g. starting with <item> (used for exporting the
|
569
|
+
# item to a file)
|
570
|
+
|
571
|
+
def to_xml
|
572
|
+
insert_item(REXML::Document.new).to_s
|
573
|
+
end
|
574
|
+
|
575
|
+
private
|
576
|
+
|
577
|
+
# Reads the tags / links / properties from the server if they are not
|
578
|
+
# already cached.
|
579
|
+
|
580
|
+
def read
|
581
|
+
if !@cached
|
582
|
+
begin
|
583
|
+
document = read_document
|
584
|
+
@links.merge!(hash_from_document(document, 'link', 'weight'))
|
585
|
+
@tags.merge(list_from_document(document, 'tag'))
|
586
|
+
|
587
|
+
document.elements.each('//property') do |element|
|
588
|
+
name = element.attribute('name').value
|
589
|
+
if !@properties.has_key?(name)
|
590
|
+
@properties[name] = element.text
|
591
|
+
end
|
592
|
+
end
|
593
|
+
|
594
|
+
@links_to_remove.each { |link| @links.delete(link) }
|
595
|
+
@tags_to_remove.each { |tag| @tags.delete(tag) }
|
596
|
+
@properties_to_remove.each { |property| @properties.delete(property) }
|
597
|
+
|
598
|
+
@links_to_remove.clear
|
599
|
+
@tags_to_remove.clear
|
600
|
+
@properties_to_remove.clear
|
601
|
+
|
602
|
+
@cached = true
|
603
|
+
rescue
|
604
|
+
puts "Couldn't read \"#{@id}\" from the database."
|
605
|
+
end
|
606
|
+
end
|
607
|
+
end
|
608
|
+
|
609
|
+
# Uploads the changes to the Directed Edge database. The optional method
|
610
|
+
# parameter may be used for either add or remove which do only incremental
|
611
|
+
# updates to the item.
|
612
|
+
|
613
|
+
def put(document, method='')
|
614
|
+
@resource[method].put(document.to_s, :content_type => 'text/xml')
|
615
|
+
end
|
616
|
+
|
617
|
+
# Creates a document for an entire item including the links, tags and
|
618
|
+
# properties.
|
619
|
+
|
620
|
+
def complete_document
|
621
|
+
document = REXML::Document.new
|
622
|
+
insert_item(document)
|
623
|
+
end
|
624
|
+
|
625
|
+
def removal_document
|
626
|
+
item = setup_document(REXML::Document.new)
|
627
|
+
@links_to_remove.each { |link| item.add_element('link').add_text(link.to_s) }
|
628
|
+
@tags_to_remove.each { |tag| item.add_element('tag').add_text(tag.to_s) }
|
629
|
+
@properties_to_remove.each do |property|
|
630
|
+
item.add_element('property').add_attribute('name', property.to_s)
|
631
|
+
end
|
632
|
+
item
|
633
|
+
end
|
634
|
+
|
635
|
+
def insert_item(document)
|
636
|
+
item = setup_document(document)
|
637
|
+
@links.each do |link, weight|
|
638
|
+
element = item.add_element('link')
|
639
|
+
if weight != 0
|
640
|
+
element.add_attribute('weight', weight.to_s)
|
641
|
+
end
|
642
|
+
element.add_text(link.to_s)
|
643
|
+
end
|
644
|
+
@tags.each { |tag| item.add_element('tag').add_text(tag.to_s) }
|
645
|
+
@properties.each do |key, value|
|
646
|
+
property = item.add_element('property')
|
647
|
+
property.add_attribute('name', key.to_s)
|
648
|
+
property.add_text(value.to_s)
|
649
|
+
end
|
650
|
+
item
|
651
|
+
end
|
652
|
+
|
653
|
+
# Creates a skeleton of an XML document for a given item.
|
654
|
+
|
655
|
+
def item_document(element, value)
|
656
|
+
document = REXML::Document.new
|
657
|
+
item = setup_document(document)
|
658
|
+
item.add_element(element).add_text(value.to_s)
|
659
|
+
document
|
660
|
+
end
|
661
|
+
|
662
|
+
# Sets up an existing XML document with the skeleton Directed Edge elements.
|
663
|
+
|
664
|
+
def setup_document(document)
|
665
|
+
directededge = document.add_element('directededge')
|
666
|
+
directededge.add_attribute('version', '0.1')
|
667
|
+
item = directededge.add_element('item')
|
668
|
+
item.add_attribute('id', @id.to_s)
|
669
|
+
item
|
670
|
+
end
|
671
|
+
end
|
672
|
+
end
|