couchdb_to_sql 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.rubocop.yml +33 -0
- data/.rubocop_todo.yml +39 -0
- data/.ruby-version +1 -0
- data/.travis.yml +12 -0
- data/.vscode/launch.json +46 -0
- data/Gemfile +11 -0
- data/LICENSE +24 -0
- data/README.md +163 -0
- data/Rakefile +28 -0
- data/VERSION +1 -0
- data/couchdb_to_sql.gemspec +32 -0
- data/examples/feed.rb +22 -0
- data/exe/couchdb_to_sql +23 -0
- data/lib/couchdb_to_sql.rb +42 -0
- data/lib/couchdb_to_sql/changes.rb +286 -0
- data/lib/couchdb_to_sql/document_handler.rb +88 -0
- data/lib/couchdb_to_sql/schema.rb +30 -0
- data/lib/couchdb_to_sql/table_builder.rb +112 -0
- data/lib/couchdb_to_sql/table_deleted_marker.rb +49 -0
- data/lib/couchdb_to_sql/table_destroyer.rb +22 -0
- data/lib/couchdb_to_sql/table_operator.rb +36 -0
- data/test/functional/functional_changes_test.rb +36 -0
- data/test/test_helper.rb +30 -0
- data/test/unit/changes_test.rb +129 -0
- data/test/unit/document_handler_test.rb +79 -0
- data/test/unit/schema_test.rb +52 -0
- data/test/unit/table_builder_test.rb +199 -0
- data/test/unit/table_destroyer_test.rb +65 -0
- metadata +233 -0
@@ -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
|
data/test/test_helper.rb
ADDED
@@ -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
|