couchdb_to_sql 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'couchdb_to_sql/table_operator'
4
+
5
+ module CouchdbToSql
6
+ #
7
+ # Table definition handler which handles database INSERT operations.
8
+ #
9
+ class TableBuilder < TableOperator
10
+ attr_reader :attributes
11
+ attr_reader :data
12
+
13
+ def initialize(parent, table_name, opts = {}, &block)
14
+ @_collections = []
15
+
16
+ @parent = parent
17
+ @data = opts[:data] || parent.document
18
+ @table_name = table_name.to_sym
19
+
20
+ deduce_primary_key(opts)
21
+
22
+ @attributes = {}
23
+ set_primary_key_attribute
24
+ set_attributes_from_data
25
+
26
+ instance_eval(&block) if block_given?
27
+ end
28
+
29
+ def id
30
+ handler.id
31
+ end
32
+
33
+ def document
34
+ parent.document
35
+ end
36
+ alias doc document
37
+
38
+ def database
39
+ @database ||= handler.database
40
+ end
41
+
42
+ #### DSL Methods
43
+
44
+ def column(*args)
45
+ column = args.first
46
+ field = args.last
47
+
48
+ if block_given?
49
+ set_attribute(column, yield)
50
+ elsif field.is_a?(Symbol)
51
+ set_attribute(column, data[field.to_s])
52
+ elsif args.length > 1
53
+ set_attribute(column, field)
54
+ end
55
+ end
56
+
57
+ #### Support Methods
58
+
59
+ def execute
60
+ # Insert the record and prepare ID for sub-tables
61
+ id = dataset.insert(attributes)
62
+ set_attribute(primary_key, id) unless id.blank?
63
+ end
64
+
65
+ private
66
+
67
+ def schema
68
+ handler.schema(table_name)
69
+ end
70
+
71
+ def dataset
72
+ database[table_name]
73
+ end
74
+
75
+ # Set the primary key in the attributes so that the insert request will have all it requires.
76
+ def set_primary_key_attribute
77
+ base = {}
78
+ base[primary_key] = id
79
+
80
+ attributes.update(base)
81
+ end
82
+
83
+ # Take the document and try to automatically set the fields from the columns
84
+ def set_attributes_from_data
85
+ return unless data.is_a?(Hash) || data.is_a?(CouchRest::Document)
86
+ data.each do |k, v|
87
+ k = k.to_sym
88
+ next if %i[_id _rev].include?(k)
89
+ set_attribute(k, v) if schema.column_names.include?(k)
90
+ end
91
+ end
92
+
93
+ def set_attribute(name, value)
94
+ name = name.to_sym
95
+ column = schema.columns[name]
96
+ return if column.nil?
97
+
98
+ # Perform basic typecasting to avoid errors with empty fields in databases that do not support them.
99
+ case column[:type]
100
+ when :string
101
+ value = value.nil? ? nil : value.to_s
102
+ when :integer
103
+ value = value.to_i
104
+ when :float
105
+ value = value.to_f
106
+ else
107
+ value = nil if value.to_s.empty?
108
+ end
109
+ attributes[name] = value
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'couchdb_to_sql/table_operator'
4
+
5
+ module CouchdbToSql
6
+ #
7
+ # Table definition handler for handling database UPSERT operations.
8
+ #
9
+ class TableDeletedMarker < TableBuilder
10
+ def execute
11
+ dataset = handler.database[table_name]
12
+
13
+ if attributes.key?(:id)
14
+ handler.changes.log_info "Deletion with 'id' field present (#{primary_key} '#{handler.id}'), assuming tombstone. " \
15
+ 'Updating data in SQL/Postgres database with data from CouchDB document.'
16
+ fields = attributes.merge(
17
+ _deleted: true,
18
+ _deleted_timestamp: Sequel::CURRENT_TIMESTAMP,
19
+ rev: handler.rev
20
+ )
21
+
22
+ dataset
23
+ .insert_conflict(
24
+ target: primary_key,
25
+ update: fields
26
+ )
27
+ .insert(fields)
28
+ else
29
+ handler.changes.log_info "Found deletion without 'id' field (#{primary_key} '#{handler.id}'), assuming plaque. Leaving " \
30
+ 'data as-is in SQL/Postgres, only setting _deleted* fields.'
31
+ records_modified = dataset
32
+ .where(key_filter)
33
+ .update(
34
+ _deleted: true,
35
+ _deleted_timestamp: Sequel::CURRENT_TIMESTAMP,
36
+ rev: handler.rev
37
+ )
38
+
39
+ handler.changes.log_info "#{records_modified} record(s) were marked as deleted"
40
+ end
41
+ end
42
+
43
+ def key_filter
44
+ {
45
+ primary_key => handler.id
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'couchdb_to_sql/table_operator'
4
+
5
+ module CouchdbToSql
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
+ class TableDestroyer < TableOperator
11
+ def execute
12
+ dataset = handler.database[table_name]
13
+ dataset.where(key_filter).delete
14
+ end
15
+
16
+ def key_filter
17
+ {
18
+ primary_key => handler.id
19
+ }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CouchdbToSql
4
+ #
5
+ # Abstract base class for classes which performs table operations (build, destroy, upsert, etc.)
6
+ #
7
+ class TableOperator
8
+ # @return [DocumentHandler]
9
+ attr_reader :parent
10
+
11
+ # @return [String]
12
+ attr_reader :table_name
13
+
14
+ # @return [Symbol]
15
+ attr_reader :primary_key
16
+
17
+ def initialize(parent, table_name, opts = {})
18
+ @parent = parent
19
+ @table_name = table_name
20
+
21
+ deduce_primary_key(opts)
22
+ end
23
+
24
+ def deduce_primary_key(opts)
25
+ @primary_key = (opts[:primary_key] || "#{@table_name.to_s.singularize}_id").to_sym
26
+ end
27
+
28
+ def handler
29
+ parent.handler
30
+ end
31
+
32
+ def execute
33
+ raise NotImplementedError, "Classes deriving from #{self} must implement the 'execute' method."
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require '../test_helper'
4
+
5
+ class FunctionalChangesTest < Test::Unit::TestCase
6
+ def setup
7
+ # Create a new CouchDB
8
+ @source = CouchRest.database('couchdb_to_sql')
9
+ create_sample_documents
10
+
11
+ # Create a new Sqlite DB in memory
12
+ @database = Sequel.sqlite
13
+ migrate_sample_database
14
+ end
15
+
16
+ def test_something
17
+ assert_equal 'foo', 'bar'
18
+ end
19
+
20
+ protected
21
+
22
+ def migrate_sample_database
23
+ @database.create_table :items do
24
+ primary_key :id
25
+ String :name
26
+ Float :price
27
+ Time :created_at
28
+ end
29
+ end
30
+
31
+ def create_sample_documents
32
+ @source.save_doc(name: 'Item 1', price: 1.23, created_at: Time.now)
33
+ @source.save_doc(name: 'Item 2', price: 2.23, created_at: Time.now)
34
+ @source.save_doc(name: 'Item 3', price: 3.23, created_at: Time.now)
35
+ end
36
+ end
@@ -0,0 +1,30 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
5
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+
7
+ require 'simplecov'
8
+
9
+ SimpleCov.start do
10
+ add_filter 'test/'
11
+ end
12
+
13
+ require 'test/unit'
14
+ require 'mocha/setup'
15
+ require 'couchdb_to_sql'
16
+
17
+ TEST_COUCHDB_URL_PREFIX = ENV.fetch('COUCHDB_URL', 'http://127.0.0.1:5984/').freeze
18
+ TEST_COUCHDB_NAME = 'couchdb_to_sql'
19
+ TEST_COUCHDB_URL = File.join(TEST_COUCHDB_URL_PREFIX, TEST_COUCHDB_NAME)
20
+ TEST_COUCHDB = CouchRest.database(TEST_COUCHDB_URL)
21
+
22
+ def reset_test_couchdb!
23
+ TEST_COUCHDB.recreate!
24
+ end
25
+
26
+ def reset_test_sql_db!(connection_string)
27
+ Sequel.connect(connection_string) do |db|
28
+ db.drop_table?(CouchdbToSql::COUCHDB_TO_SQL_SEQUENCES_TABLE)
29
+ end
30
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class ChangesTest < Test::Unit::TestCase
6
+ def setup
7
+ reset_test_couchdb!
8
+ reset_test_sql_db!(sql_connection_string)
9
+ build_sample_config
10
+ end
11
+
12
+ def test_basic_init
13
+ @database = @changes.database
14
+ assert @changes.database, 'Did not assign a database'
15
+ assert @changes.database.is_a?(Sequel::Database)
16
+ row = @database[CouchdbToSql::COUCHDB_TO_SQL_SEQUENCES_TABLE].first
17
+ assert row, "Did not create a #{CouchdbToSql::COUCHDB_TO_SQL_SEQUENCES_TABLE} table"
18
+ assert_equal row.fetch(:highest_sequence), '0', 'Did not set a default sequence number'
19
+ assert_equal row.fetch(:couchdb_database_name), TEST_COUCHDB_NAME
20
+ end
21
+
22
+ def test_defining_document_handler
23
+ assert_equal @changes.handlers.length, 3
24
+ handler = @changes.handlers.first
25
+ assert handler.is_a?(CouchdbToSql::DocumentHandler)
26
+ assert_equal handler.filter, type: 'Foo'
27
+ end
28
+
29
+ def test_inserting_rows
30
+ doc = {
31
+ '_id' => '1234',
32
+ 'type' => 'Foo',
33
+ 'name' => 'Some Document'
34
+ }
35
+ row = {
36
+ 'seq' => 1,
37
+ 'id' => '1234',
38
+ 'doc' => doc
39
+ }
40
+
41
+ handler = @changes.handlers.first
42
+ handler.expects(:delete).with(doc)
43
+ handler.expects(:insert).with(doc)
44
+
45
+ @changes.send(:process_row, row)
46
+
47
+ # Should update seq
48
+ assert_equal @changes.database[CouchdbToSql::COUCHDB_TO_SQL_SEQUENCES_TABLE].first[:highest_sequence], '1'
49
+ end
50
+
51
+ def test_inserting_rows_with_multiple_filters
52
+ row = {
53
+ 'seq' => 3,
54
+ 'id' => '1234',
55
+ 'doc' => {
56
+ 'type' => 'Bar',
57
+ 'special' => true,
58
+ 'name' => 'Some Document'
59
+ }
60
+ }
61
+
62
+ handler = @changes.handlers[0]
63
+ handler.expects(:insert).never
64
+
65
+ handler = @changes.handlers[1]
66
+ handler.expects(:delete)
67
+ handler.expects(:insert)
68
+
69
+ handler = @changes.handlers[2]
70
+ handler.expects(:delete)
71
+ handler.expects(:insert)
72
+
73
+ @changes.send(:process_row, row)
74
+ assert_equal @changes.database[CouchdbToSql::COUCHDB_TO_SQL_SEQUENCES_TABLE].first.fetch(:highest_sequence), '3'
75
+ end
76
+
77
+ def test_deleting_rows
78
+ doc = {
79
+ 'order_number' => '12345',
80
+ 'customer_number' => '54321'
81
+ }
82
+ row = {
83
+ 'seq' => 9,
84
+ 'id' => '1234',
85
+ 'deleted' => true,
86
+ 'doc' => doc
87
+ }
88
+
89
+ @changes.handlers.each do |handler|
90
+ handler.expects(:mark_as_deleted).with(doc)
91
+ end
92
+
93
+ @changes.send(:process_row, row)
94
+
95
+ assert_equal @changes.database[CouchdbToSql::COUCHDB_TO_SQL_SEQUENCES_TABLE].first.fetch(:highest_sequence), '9'
96
+ end
97
+
98
+ def test_returning_schema
99
+ schema = mock
100
+ CouchdbToSql::Schema.expects(:new).once.with(@changes.database, :items).returns(schema)
101
+
102
+ # Run twice to ensure cached
103
+ assert_equal @changes.schema(:items), schema
104
+ assert_equal @changes.schema(:items), schema
105
+ end
106
+
107
+ protected
108
+
109
+ def build_sample_config
110
+ connection_string = sql_connection_string
111
+
112
+ @changes = CouchdbToSql::Changes.new(TEST_COUCHDB_URL) do
113
+ database connection_string
114
+
115
+ document type: 'Foo' do
116
+ end
117
+
118
+ document type: 'Bar' do
119
+ end
120
+
121
+ document type: 'Bar', special: true do
122
+ end
123
+ end
124
+ end
125
+
126
+ def sql_connection_string
127
+ ENV.fetch('TEST_SQL_URL', 'sqlite:/')
128
+ end
129
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class DocumentHandlerTest < Test::Unit::TestCase
6
+ def test_init
7
+ @handler = CouchdbToSql::DocumentHandler.new 'changes' do
8
+ # nothing
9
+ end
10
+ assert_equal @handler.changes, 'changes'
11
+ end
12
+
13
+ def test_handles_with_basic_hash
14
+ @handler = CouchdbToSql::DocumentHandler.new 'changes', type: 'Item'
15
+ doc = { 'type' => 'Item', '_id' => '1234' }
16
+ assert @handler.handles?(doc)
17
+ doc = { 'type' => 'Client', '_id' => '1234' }
18
+ assert !@handler.handles?(doc)
19
+ end
20
+
21
+ def test_handles_with_multi_level_hash
22
+ @handler = CouchdbToSql::DocumentHandler.new 'changes', type: 'Item', foo: 'bar'
23
+ doc = { 'type' => 'Item', 'foo' => 'bar', '_id' => '1234' }
24
+ assert @handler.handles?(doc)
25
+ doc = { 'type' => 'Item', '_id' => '1234' }
26
+ assert !@handler.handles?(doc)
27
+ doc = { 'foor' => 'bar', '_id' => '1234' }
28
+ assert !@handler.handles?(doc)
29
+ end
30
+
31
+ def test_id
32
+ @handler = CouchdbToSql::DocumentHandler.new 'changes' do
33
+ table :items
34
+ end
35
+ @handler.document = { '_id' => '12345' }
36
+ assert_equal @handler.id, '12345'
37
+ end
38
+
39
+ def test_insert
40
+ @handler = CouchdbToSql::DocumentHandler.new 'changes' do
41
+ table :items
42
+ end
43
+ @handler.expects(:table).with(:items)
44
+ doc = { 'type' => 'Foo', '_id' => '1234' }
45
+ @handler.insert(doc)
46
+ assert_equal @handler.document, doc
47
+ end
48
+
49
+ def test_delete
50
+ @handler = CouchdbToSql::DocumentHandler.new 'changes' do
51
+ table :items
52
+ end
53
+ @handler.expects(:table).with(:items)
54
+ @handler.delete('_id' => '1234')
55
+ assert_equal @handler.id, '1234'
56
+ end
57
+
58
+ def test_table_definition_on_delete
59
+ @handler = CouchdbToSql::DocumentHandler.new 'changes' do
60
+ @mode = :delete # Force delete mode!
61
+ end
62
+ @handler.instance_eval('@mode = :delete')
63
+ @table = mock
64
+ @table.expects(:execute)
65
+ CouchdbToSql::TableDestroyer.expects(:new).with(@handler, :items, {}).returns(@table)
66
+ @handler.table(:items)
67
+ end
68
+
69
+ def test_table_definition_on_insert
70
+ @handler = CouchdbToSql::DocumentHandler.new 'changes' do
71
+ # Force insert mode!
72
+ end
73
+ @handler.instance_eval('@mode = :insert')
74
+ @table = mock
75
+ @table.expects(:execute)
76
+ CouchdbToSql::TableBuilder.expects(:new).with(@handler, :items, {}).returns(@table)
77
+ @handler.table(:items)
78
+ end
79
+ end