couch_tap 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +59 -0
- data/README.md +183 -0
- data/Rakefile +14 -0
- data/VERSION +1 -0
- data/bin/couch_tap +13 -0
- data/couch_tap.gemspec +22 -0
- data/examples/feed.rb +27 -0
- data/lib/couch_tap.rb +48 -0
- data/lib/couch_tap/builders/collection.rb +41 -0
- data/lib/couch_tap/builders/table.rb +161 -0
- data/lib/couch_tap/changes.rb +160 -0
- data/lib/couch_tap/destroyers/collection.rb +36 -0
- data/lib/couch_tap/destroyers/table.rb +76 -0
- data/lib/couch_tap/document_handler.rb +73 -0
- data/lib/couch_tap/schema.rb +32 -0
- data/test/functional/functional_changes_test.rb +37 -0
- data/test/test_helper.rb +16 -0
- data/test/unit/builders/collection_test.rb +74 -0
- data/test/unit/builders/table_test.rb +259 -0
- data/test/unit/changes_test.rb +95 -0
- data/test/unit/destroyers/collection_test.rb +55 -0
- data/test/unit/destroyers/table_test.rb +120 -0
- data/test/unit/document_handler_test.rb +80 -0
- data/test/unit/schema_test.rb +52 -0
- metadata +180 -0
@@ -0,0 +1,161 @@
|
|
1
|
+
|
2
|
+
module CouchTap
|
3
|
+
|
4
|
+
module Builders
|
5
|
+
|
6
|
+
#
|
7
|
+
# Deal with a table definition that will automatically insert
|
8
|
+
# a new row into the table.
|
9
|
+
#
|
10
|
+
class Table
|
11
|
+
|
12
|
+
attr_reader :attributes
|
13
|
+
attr_reader :parent, :name, :data, :primary_keys
|
14
|
+
|
15
|
+
def initialize(parent, name, opts = {}, &block)
|
16
|
+
@_collections = []
|
17
|
+
|
18
|
+
@parent = parent
|
19
|
+
@data = opts[:data] || parent.document
|
20
|
+
@name = name.to_sym
|
21
|
+
|
22
|
+
@primary_keys = parent.primary_keys.dup
|
23
|
+
unless opts[:primary_key] === false
|
24
|
+
@primary_keys << (opts[:primary_key] || "#{@name.to_s.singularize}_id").to_sym
|
25
|
+
end
|
26
|
+
|
27
|
+
# Prepare the attributes
|
28
|
+
@attributes = {}
|
29
|
+
set_primary_keys
|
30
|
+
set_attributes_from_data
|
31
|
+
|
32
|
+
instance_eval(&block) if block_given?
|
33
|
+
end
|
34
|
+
|
35
|
+
def handler
|
36
|
+
parent.handler
|
37
|
+
end
|
38
|
+
|
39
|
+
def id
|
40
|
+
handler.id
|
41
|
+
end
|
42
|
+
|
43
|
+
def document
|
44
|
+
parent.document
|
45
|
+
end
|
46
|
+
alias doc document
|
47
|
+
|
48
|
+
def database
|
49
|
+
@database ||= handler.database
|
50
|
+
end
|
51
|
+
|
52
|
+
# Grab the latest set of values to filter with.
|
53
|
+
# This is only relevant in sub-tables.
|
54
|
+
def key_filter
|
55
|
+
hash = {}
|
56
|
+
primary_keys.each do |k|
|
57
|
+
hash[k] = attributes[k]
|
58
|
+
end
|
59
|
+
hash
|
60
|
+
end
|
61
|
+
|
62
|
+
#### DSL Methods
|
63
|
+
|
64
|
+
def column(*args)
|
65
|
+
column = args.first
|
66
|
+
field = args.last
|
67
|
+
if block_given?
|
68
|
+
set_attribute(column, yield)
|
69
|
+
elsif field.is_a?(Symbol)
|
70
|
+
set_attribute(column, data[field.to_s])
|
71
|
+
elsif args.length > 1
|
72
|
+
set_attribute(column, field)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def collection(field, opts = {}, &block)
|
77
|
+
@_collections << Collection.new(self, field, opts, &block)
|
78
|
+
end
|
79
|
+
|
80
|
+
#### Support Methods
|
81
|
+
|
82
|
+
def execute
|
83
|
+
# Insert the record and prepare ID for sub-tables
|
84
|
+
id = dataset.insert(attributes)
|
85
|
+
set_attribute(primary_keys.last, id) unless id.blank?
|
86
|
+
|
87
|
+
# Now go through each collection entry
|
88
|
+
if @_collections.length > 0
|
89
|
+
@_collections.each do |collection|
|
90
|
+
collection.execute
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def schema
|
99
|
+
handler.schema(name)
|
100
|
+
end
|
101
|
+
|
102
|
+
def dataset
|
103
|
+
database[name]
|
104
|
+
end
|
105
|
+
|
106
|
+
def find_existing_row_and_set_attributes
|
107
|
+
row = dataset.where(key_filter).first
|
108
|
+
attributes.update(row) if row.present?
|
109
|
+
end
|
110
|
+
|
111
|
+
# Set the primary keys in the attributes so that the insert request
|
112
|
+
# will have all it requires.
|
113
|
+
#
|
114
|
+
# This methods has two modes of operation to handle the first table
|
115
|
+
# definition and sub-tables.
|
116
|
+
#
|
117
|
+
def set_primary_keys
|
118
|
+
base = parent.key_filter.dup
|
119
|
+
|
120
|
+
# Are we dealing with the first table?
|
121
|
+
base[primary_keys.first] = id if base.empty?
|
122
|
+
|
123
|
+
attributes.update(base)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Take the document and try to automatically set the fields from the columns
|
127
|
+
def set_attributes_from_data
|
128
|
+
return unless data.is_a?(Hash) || data.is_a?(CouchRest::Document)
|
129
|
+
data.each do |k,v|
|
130
|
+
k = k.to_sym
|
131
|
+
next if k == :_id || k == :_rev
|
132
|
+
if schema.column_names.include?(k)
|
133
|
+
set_attribute(k, v)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def set_attribute(name, value)
|
139
|
+
name = name.to_sym
|
140
|
+
column = schema.columns[name]
|
141
|
+
return if column.nil?
|
142
|
+
# Perform basic typecasting to avoid errors with empty fields
|
143
|
+
# in databases that do not support them.
|
144
|
+
case column[:type]
|
145
|
+
when :string
|
146
|
+
value = value.nil? ? nil : value.to_s
|
147
|
+
when :integer
|
148
|
+
value = value.to_i
|
149
|
+
when :float
|
150
|
+
value = value.to_f
|
151
|
+
else
|
152
|
+
value = nil if value.to_s.empty?
|
153
|
+
end
|
154
|
+
attributes[name] = value
|
155
|
+
end
|
156
|
+
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
@@ -0,0 +1,160 @@
|
|
1
|
+
|
2
|
+
module CouchTap
|
3
|
+
class Changes
|
4
|
+
|
5
|
+
COUCHDB_HEARTBEAT = 30
|
6
|
+
INACTIVITY_TIMEOUT = 70
|
7
|
+
RECONNECT_TIMEOUT = 15
|
8
|
+
|
9
|
+
attr_reader :source, :database, :schemas, :handlers
|
10
|
+
|
11
|
+
attr_accessor :seq
|
12
|
+
|
13
|
+
# Start a new Changes instance by connecting to the provided
|
14
|
+
# CouchDB to see if the database exists.
|
15
|
+
def initialize(opts = "", &block)
|
16
|
+
raise "Block required for changes!" unless block_given?
|
17
|
+
|
18
|
+
@schemas = {}
|
19
|
+
@handlers = []
|
20
|
+
@source = CouchRest.database(opts)
|
21
|
+
info = @source.info
|
22
|
+
|
23
|
+
logger.info "Connected to CouchDB: #{info['db_name']}"
|
24
|
+
|
25
|
+
# Prepare the definitions
|
26
|
+
instance_eval(&block)
|
27
|
+
end
|
28
|
+
|
29
|
+
#### DSL
|
30
|
+
|
31
|
+
# Dual-purpose method, accepts configuration of database
|
32
|
+
# or returns a previous definition.
|
33
|
+
def database(opts = nil)
|
34
|
+
if opts
|
35
|
+
@database ||= Sequel.connect(opts)
|
36
|
+
find_or_create_sequence_number
|
37
|
+
end
|
38
|
+
@database
|
39
|
+
end
|
40
|
+
|
41
|
+
def document(filter = {}, &block)
|
42
|
+
@handlers << DocumentHandler.new(self, filter, &block)
|
43
|
+
end
|
44
|
+
|
45
|
+
#### END DSL
|
46
|
+
|
47
|
+
def schema(name)
|
48
|
+
@schemas[name.to_sym] ||= Schema.new(database, name)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Start listening to the CouchDB changes feed. Must be called from
|
52
|
+
# a EventMachine run block for the HttpRequest to take control.
|
53
|
+
# By this stage we should have a sequence id so we know where to start from
|
54
|
+
# and all the filters should have been prepared.
|
55
|
+
def start
|
56
|
+
prepare_parser
|
57
|
+
perform_request
|
58
|
+
end
|
59
|
+
|
60
|
+
protected
|
61
|
+
|
62
|
+
def perform_request
|
63
|
+
logger.info "Listening to changes feed from seq: #{seq}"
|
64
|
+
|
65
|
+
unless @request
|
66
|
+
url = File.join(source.root, '_changes')
|
67
|
+
@request = EventMachine::HttpRequest.new(url, :inactivity_timeout => INACTIVITY_TIMEOUT)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Make sure the request has the latest sequence
|
71
|
+
query = {:since => seq, :feed => 'continuous', :heartbeat => COUCHDB_HEARTBEAT * 1000}
|
72
|
+
@http = @request.get(:query => query, :keepalive => true)
|
73
|
+
|
74
|
+
# Handle incoming data
|
75
|
+
@http.stream do |chunk|
|
76
|
+
# logger.debug chunk.strip
|
77
|
+
@parser << chunk
|
78
|
+
end
|
79
|
+
|
80
|
+
# Handle error events
|
81
|
+
@http.errback do |err|
|
82
|
+
logger.error "Connection Failed: #{err.error}, attempting to reconnect in #{RECONNECT_TIMEOUT}s..."
|
83
|
+
EM.add_timer RECONNECT_TIMEOUT do
|
84
|
+
perform_request
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def prepare_parser
|
90
|
+
@parser = Yajl::Parser.new
|
91
|
+
@parser.on_parse_complete = method(:process_row)
|
92
|
+
@parser
|
93
|
+
end
|
94
|
+
|
95
|
+
def process_row(row)
|
96
|
+
id = row['id']
|
97
|
+
|
98
|
+
# Sometimes CouchDB will send an update to keep the connection alive
|
99
|
+
if id
|
100
|
+
seq = row['seq']
|
101
|
+
|
102
|
+
# Wrap the whole request in a transaction
|
103
|
+
database.transaction do
|
104
|
+
if row['deleted']
|
105
|
+
# Delete all the entries
|
106
|
+
logger.info "Received delete seq. #{seq} id: #{id}"
|
107
|
+
handlers.each{ |handler| handler.delete('_id' => id) }
|
108
|
+
else
|
109
|
+
logger.info "Received change seq. #{seq} id: #{id}"
|
110
|
+
doc = fetch_document(id)
|
111
|
+
find_document_handlers(doc).each do |handler|
|
112
|
+
# Delete all previous entries of doc, then re-create
|
113
|
+
handler.delete(doc)
|
114
|
+
handler.insert(doc)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
update_sequence(seq)
|
119
|
+
end # transaction
|
120
|
+
|
121
|
+
elsif row['last_seq']
|
122
|
+
logger.info "Received last seq: #{row['last_seq']}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def fetch_document(id)
|
127
|
+
source.get(id)
|
128
|
+
end
|
129
|
+
|
130
|
+
def find_document_handlers(document)
|
131
|
+
@handlers.reject{ |row| !row.handles?(document) }
|
132
|
+
end
|
133
|
+
|
134
|
+
def find_or_create_sequence_number
|
135
|
+
create_sequence_table unless database.table_exists?(:couch_sequence)
|
136
|
+
self.seq = database[:couch_sequence].where(:name => source.name).first[:seq]
|
137
|
+
end
|
138
|
+
|
139
|
+
def update_sequence(seq)
|
140
|
+
database[:couch_sequence].where(:name => source.name).update(:seq => seq)
|
141
|
+
self.seq = seq
|
142
|
+
end
|
143
|
+
|
144
|
+
def create_sequence_table
|
145
|
+
database.create_table :couch_sequence do
|
146
|
+
String :name, :primary_key => true
|
147
|
+
Bignum :seq, :default => 0
|
148
|
+
DateTime :created_at
|
149
|
+
DateTime :updated_at
|
150
|
+
end
|
151
|
+
# Add first row
|
152
|
+
database[:couch_sequence].insert(:name => source.name)
|
153
|
+
end
|
154
|
+
|
155
|
+
def logger
|
156
|
+
CouchTap.logger
|
157
|
+
end
|
158
|
+
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
|
2
|
+
module CouchTap
|
3
|
+
|
4
|
+
module Destroyers
|
5
|
+
|
6
|
+
#
|
7
|
+
# Collection Destroyer. Go through each sub-table definition and remove
|
8
|
+
# all references to the parent document.
|
9
|
+
#
|
10
|
+
class Collection
|
11
|
+
|
12
|
+
attr_reader :parent
|
13
|
+
|
14
|
+
def initialize(parent, opts = {}, &block)
|
15
|
+
@_tables = []
|
16
|
+
@parent = parent
|
17
|
+
|
18
|
+
instance_eval(&block)
|
19
|
+
end
|
20
|
+
|
21
|
+
def execute
|
22
|
+
# Just go through each table and ask it to execute itself
|
23
|
+
@_tables.each do |table|
|
24
|
+
table.execute
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
#### DSL Methods
|
29
|
+
|
30
|
+
def table(name, opts = {}, &block)
|
31
|
+
@_tables << Table.new(parent, name, opts, &block)
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
|
2
|
+
module CouchTap
|
3
|
+
|
4
|
+
module Destroyers
|
5
|
+
|
6
|
+
#
|
7
|
+
# The table destroyer will go through a table definition and make sure that
|
8
|
+
# all rows that belong to the document's id are deleted from the system.
|
9
|
+
#
|
10
|
+
# It'll automatically go through each collection definition and recursively
|
11
|
+
# ensure that everything has been cleaned up.
|
12
|
+
#
|
13
|
+
class Table
|
14
|
+
|
15
|
+
attr_reader :parent, :name, :primary_keys
|
16
|
+
|
17
|
+
def initialize(parent, name, opts = {}, &block)
|
18
|
+
@_collections = []
|
19
|
+
|
20
|
+
@parent = parent
|
21
|
+
@name = name
|
22
|
+
|
23
|
+
@primary_keys = parent.primary_keys.dup
|
24
|
+
|
25
|
+
# As we're deleting, only assign the primary key for the first table
|
26
|
+
if @primary_keys.empty?
|
27
|
+
@primary_keys << (opts[:primary_key] || "#{@name.to_s.singularize}_id").to_sym
|
28
|
+
end
|
29
|
+
|
30
|
+
instance_eval(&block) if block_given?
|
31
|
+
end
|
32
|
+
|
33
|
+
def execute
|
34
|
+
dataset = handler.database[name]
|
35
|
+
dataset.where(key_filter).delete
|
36
|
+
@_collections.each do |collection|
|
37
|
+
collection.execute
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def handler
|
42
|
+
parent.handler
|
43
|
+
end
|
44
|
+
|
45
|
+
# Unlike building new rows, delete only requires the main primary key to be available.
|
46
|
+
def key_filter
|
47
|
+
{
|
48
|
+
@primary_keys.first => handler.id
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
### DSL methods
|
53
|
+
|
54
|
+
def collection(field, opts = {}, &block)
|
55
|
+
@_collections << Collection.new(self, opts, &block)
|
56
|
+
end
|
57
|
+
|
58
|
+
### Dummy helper methods
|
59
|
+
|
60
|
+
def column(*args)
|
61
|
+
nil
|
62
|
+
end
|
63
|
+
|
64
|
+
def document
|
65
|
+
{}
|
66
|
+
end
|
67
|
+
alias doc document
|
68
|
+
|
69
|
+
def data
|
70
|
+
{}
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module CouchTap
|
2
|
+
|
3
|
+
class DocumentHandler
|
4
|
+
|
5
|
+
attr_reader :changes, :filter, :mode
|
6
|
+
attr_accessor :id, :document
|
7
|
+
|
8
|
+
def initialize(changes, filter = {}, &block)
|
9
|
+
@changes = changes
|
10
|
+
@filter = filter
|
11
|
+
@_block = block
|
12
|
+
@mode = nil
|
13
|
+
end
|
14
|
+
|
15
|
+
def handles?(doc)
|
16
|
+
@filter.each do |k,v|
|
17
|
+
return false if doc[k.to_s] != v
|
18
|
+
end
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
### START DSL
|
23
|
+
|
24
|
+
# Handle a table definition.
|
25
|
+
def table(name, opts = {}, &block)
|
26
|
+
if @mode == :delete
|
27
|
+
Destroyers::Table.new(self, name, opts, &block).execute
|
28
|
+
elsif @mode == :insert
|
29
|
+
Builders::Table.new(self, name, opts, &block).execute
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
### END DSL
|
34
|
+
|
35
|
+
def handler
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
def primary_keys
|
40
|
+
[]
|
41
|
+
end
|
42
|
+
|
43
|
+
def key_filter
|
44
|
+
{}
|
45
|
+
end
|
46
|
+
|
47
|
+
def id
|
48
|
+
document['_id']
|
49
|
+
end
|
50
|
+
|
51
|
+
def insert(document)
|
52
|
+
@mode = :insert
|
53
|
+
self.document = document
|
54
|
+
instance_eval(&@_block)
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete(document)
|
58
|
+
@mode = :delete
|
59
|
+
self.document = document
|
60
|
+
instance_eval(&@_block)
|
61
|
+
end
|
62
|
+
|
63
|
+
def schema(name)
|
64
|
+
changes.schema(name)
|
65
|
+
end
|
66
|
+
|
67
|
+
def database
|
68
|
+
changes.database
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|