directed-edge 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|