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