couch_tap 0.0.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.
- 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
|