couchdb_to_sql 1.0.0

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,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