couch_tap 0.0.2

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