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.
@@ -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