tarantool-model 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +16 -1
- data/README.md +123 -15
- data/lib/tarantool/model/errors.rb +11 -0
- data/lib/tarantool/model/locking.rb +90 -0
- data/lib/tarantool/model/persistence.rb +10 -2
- data/lib/tarantool/model/settings.rb +10 -1
- data/lib/tarantool/model/storage/box/layout.rb +1 -10
- data/lib/tarantool/model/storage/box.rb +14 -25
- data/lib/tarantool/model/storage/crud.rb +2 -0
- data/lib/tarantool/model/testing/memory_storage/box_mode.rb +49 -0
- data/lib/tarantool/model/testing/memory_storage/crud_mode.rb +81 -0
- data/lib/tarantool/model/testing/memory_storage/declaration.rb +54 -0
- data/lib/tarantool/model/testing/memory_storage/index_declaration.rb +78 -0
- data/lib/tarantool/model/testing/memory_storage/keys.rb +38 -0
- data/lib/tarantool/model/testing/memory_storage/operations.rb +38 -0
- data/lib/tarantool/model/testing/memory_storage/order.rb +44 -0
- data/lib/tarantool/model/testing/memory_storage/scan.rb +82 -0
- data/lib/tarantool/model/testing/memory_storage/sequence.rb +28 -0
- data/lib/tarantool/model/testing/memory_storage/space.rb +141 -0
- data/lib/tarantool/model/testing/memory_storage/violation.rb +71 -0
- data/lib/tarantool/model/testing/memory_storage.rb +99 -0
- data/lib/tarantool/model/testing.rb +17 -0
- data/lib/tarantool/model/version.rb +1 -1
- data/lib/tarantool/model.rb +18 -0
- metadata +18 -4
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# One space in tarantool-schema's ensure_space shape, checked and turned into the SpaceInfo that
|
|
8
|
+
# Storage::Box::Layout reads. Types are checked here but values are not type-checked.
|
|
9
|
+
# @api private
|
|
10
|
+
class Declaration
|
|
11
|
+
SPACE_KEYS = %i[engine format indexes].freeze
|
|
12
|
+
FIELD_KEYS = %i[name type is_nullable].freeze
|
|
13
|
+
TYPES = %w[unsigned string integer number double boolean decimal uuid datetime interval varbinary scalar
|
|
14
|
+
array map any].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :space_info, :sequence
|
|
17
|
+
|
|
18
|
+
def initialize(name, schema)
|
|
19
|
+
owner = "space #{name}"
|
|
20
|
+
Keys.check!(schema, SPACE_KEYS, owner)
|
|
21
|
+
fields = Keys.fetch!(schema, :format, owner).map { |entry| field(entry, owner) }
|
|
22
|
+
Keys.distinct!(fields.map(&:name), owner)
|
|
23
|
+
indexes = index_declarations(Keys.fetch!(schema, :indexes, owner), fields, owner)
|
|
24
|
+
@space_info = build_space_info(name, fields, indexes)
|
|
25
|
+
@sequence = indexes.first.sequence
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_space_info(name, fields, indexes)
|
|
31
|
+
Tarantool::Client::SpaceInfo.new(
|
|
32
|
+
id: 0, name: name.to_s, fields:, indexes: indexes.to_h { |index| [index.info.name, index.info] }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def field(entry, owner)
|
|
37
|
+
Keys.check!(entry, FIELD_KEYS, "#{owner} field")
|
|
38
|
+
name = Keys.fetch!(entry, :name, "#{owner} field").to_s
|
|
39
|
+
type = Keys.one_of!(entry.fetch(:type, 'any'), TYPES, "#{owner} field #{name}")
|
|
40
|
+
|
|
41
|
+
Tarantool::Client::SpaceInfo::Field.new(name:, type:, is_nullable: entry.fetch(:is_nullable, false))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def index_declarations(entries, fields, owner)
|
|
45
|
+
raise ArgumentError, "#{owner} has no indexes" if entries.empty?
|
|
46
|
+
|
|
47
|
+
entries.each_with_index.map { |entry, id| IndexDeclaration.new(entry, id:, fields:, owner:) }
|
|
48
|
+
.tap { |indexes| Keys.distinct!(indexes.map { |index| index.info.name }, owner) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# One index of a space declaration; the first index of a space is the primary one.
|
|
8
|
+
# @api private
|
|
9
|
+
class IndexDeclaration
|
|
10
|
+
KEYS = %i[name parts unique type sequence].freeze
|
|
11
|
+
PART_KEYS = %i[field type is_nullable path].freeze
|
|
12
|
+
TYPES = %w[tree hash].freeze
|
|
13
|
+
SEQUENCE_TYPES = %w[unsigned integer].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :info, :sequence
|
|
16
|
+
|
|
17
|
+
def initialize(entry, id:, fields:, owner:)
|
|
18
|
+
Keys.check!(entry, KEYS, "#{owner} index")
|
|
19
|
+
@owner = "#{owner} index #{Keys.fetch!(entry, :name, "#{owner} index")}"
|
|
20
|
+
@fields = fields
|
|
21
|
+
@info = Tarantool::Client::IndexInfo.new(
|
|
22
|
+
id:, name: entry[:name].to_s, type: Keys.one_of!(entry.fetch(:type, 'tree'), TYPES, @owner),
|
|
23
|
+
unique: entry.fetch(:unique, true), parts: parts(Keys.fetch!(entry, :parts, @owner))
|
|
24
|
+
)
|
|
25
|
+
check_unique!
|
|
26
|
+
check_nullable!
|
|
27
|
+
@sequence = sequence?(entry.fetch(:sequence, false))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parts(entries)
|
|
33
|
+
raise ArgumentError, "#{@owner} has no parts" if entries.empty?
|
|
34
|
+
|
|
35
|
+
entries.map { |entry| part(entry.is_a?(Hash) ? entry : { field: entry }) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def part(entry)
|
|
39
|
+
Keys.check!(entry, PART_KEYS, "#{@owner} part")
|
|
40
|
+
raise ArgumentError, "#{@owner}: path parts are not supported" if entry[:path]
|
|
41
|
+
|
|
42
|
+
name = Keys.fetch!(entry, :field, "#{@owner} part").to_s
|
|
43
|
+
fieldno = @fields.index { |field| field.name == name }
|
|
44
|
+
raise ArgumentError, "#{@owner} has no field #{name}" unless fieldno
|
|
45
|
+
|
|
46
|
+
field = @fields[fieldno]
|
|
47
|
+
type = Keys.one_of!(entry.fetch(:type, field.type), Declaration::TYPES, "#{@owner} part #{name}")
|
|
48
|
+
|
|
49
|
+
Tarantool::Client::IndexInfo::Part.new(
|
|
50
|
+
fieldno:, name:, type:, is_nullable: entry.fetch(:is_nullable, field.is_nullable), path: nil
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_unique!
|
|
55
|
+
return if @info.unique
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "#{@owner}: a primary index must be unique" if @info.id.zero?
|
|
58
|
+
raise ArgumentError, "#{@owner}: a hash index must be unique" if @info.type == 'hash'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def check_nullable!
|
|
62
|
+
return unless @info.parts.any?(&:is_nullable)
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "#{@owner}: a primary index can not contain nullable parts" if @info.id.zero?
|
|
65
|
+
raise ArgumentError, "#{@owner}: a hash index does not support nullable parts" if @info.type == 'hash'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sequence?(requested)
|
|
69
|
+
return false unless requested
|
|
70
|
+
return true if @info.id.zero? && @info.parts.size == 1 && SEQUENCE_TYPES.include?(@info.parts.first.type)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "#{@owner}: a sequence needs a primary index with one unsigned or integer part"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# Shape checks shared by the declaration classes; a bad declaration raises ArgumentError naming its owner.
|
|
8
|
+
# @api private
|
|
9
|
+
module Keys
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def check!(declaration, allowed, owner)
|
|
13
|
+
raise ArgumentError, "#{owner} must be a Hash, got #{declaration.inspect}" unless declaration.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
unknown = declaration.keys - allowed
|
|
16
|
+
raise ArgumentError, "#{owner} has unknown keys #{unknown.join(', ')}" if unknown.any?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch!(declaration, key, owner)
|
|
20
|
+
declaration.fetch(key) { raise ArgumentError, "#{owner} has no #{key}" }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def one_of!(value, allowed, owner)
|
|
24
|
+
name = value.to_s
|
|
25
|
+
raise ArgumentError, "#{owner} has unknown type #{value.inspect}" unless allowed.include?(name)
|
|
26
|
+
|
|
27
|
+
name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def distinct!(names, owner)
|
|
31
|
+
duplicates = names.tally.select { |_name, count| count > 1 }.keys
|
|
32
|
+
raise ArgumentError, "#{owner} has duplicate names #{duplicates.join(', ')}" if duplicates.any?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# Update operators the model sends: '=' from save, '+' and '-' from the counters. Returns a new tuple.
|
|
8
|
+
# @api private
|
|
9
|
+
class Operations
|
|
10
|
+
ARITHMETIC = %w[+ -].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(space_info)
|
|
13
|
+
@names = space_info.fields.map(&:name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def apply(tuple, operations)
|
|
17
|
+
operations.each_with_object(tuple.dup) do |(operator, name, value), result|
|
|
18
|
+
fieldno = @names.index(name.to_s) || raise(Violation.unknown_field(name))
|
|
19
|
+
result[fieldno] = value_after(operator.to_s, name, result[fieldno], value)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def value_after(operator, name, current, value)
|
|
26
|
+
return value if operator == '='
|
|
27
|
+
unless ARITHMETIC.include?(operator)
|
|
28
|
+
raise NotSupportedError, "MemoryStorage has no #{operator} update operator"
|
|
29
|
+
end
|
|
30
|
+
raise Violation.operand(operator, name) unless current.is_a?(Numeric) && value.is_a?(Numeric)
|
|
31
|
+
|
|
32
|
+
current.public_send(operator, value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# Tree index order of keys of equal length: nil first, as MP_NIL, then false before true, then Ruby's
|
|
8
|
+
# <=>. Values Ruby cannot compare, such as mixed types in a scalar field, raise ArgumentError.
|
|
9
|
+
# @api private
|
|
10
|
+
module Order
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def compare(left, right)
|
|
14
|
+
left.zip(right).each do |a, b|
|
|
15
|
+
result = value(a, b)
|
|
16
|
+
return result unless result.zero?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def value(left, right)
|
|
23
|
+
return nil_rank(left) <=> nil_rank(right) if left.nil? || right.nil?
|
|
24
|
+
return as_int(left) <=> as_int(right) if boolean?(left) && boolean?(right)
|
|
25
|
+
|
|
26
|
+
(left <=> right) || raise(ArgumentError, "MemoryStorage cannot order #{left.inspect} and #{right.inspect}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def nil_rank(value)
|
|
30
|
+
value.nil? ? 0 : 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def boolean?(value)
|
|
34
|
+
[true, false].include?(value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def as_int(value)
|
|
38
|
+
value ? 1 : 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# Tuples one select or count sees through an index: ordered by the index key, then the primary key (le and
|
|
8
|
+
# lt walk backwards), filtered by the key prefix condition, then continued strictly past `after`.
|
|
9
|
+
# @api private
|
|
10
|
+
class Scan
|
|
11
|
+
MATCHES = {
|
|
12
|
+
eq: :zero?.to_proc, ge: ->(result) { result >= 0 }, gt: :positive?.to_proc,
|
|
13
|
+
le: ->(result) { result <= 0 }, lt: :negative?.to_proc, all: ->(_result) { true }
|
|
14
|
+
}.freeze
|
|
15
|
+
DESCENDING = %i[le lt].freeze
|
|
16
|
+
HASH_ITERATORS = %i[eq gt all].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(space_info, index)
|
|
19
|
+
@space_info = space_info
|
|
20
|
+
@index = index
|
|
21
|
+
@primary = space_info.primary_index
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(tuples, key:, iterator:, after:)
|
|
25
|
+
match = matcher(iterator)
|
|
26
|
+
raise Violation.key_part_count(@index.parts.size, key.size) if key.size > @index.parts.size
|
|
27
|
+
|
|
28
|
+
check_hash!(key, iterator, after)
|
|
29
|
+
sign = sign_for(iterator)
|
|
30
|
+
found = matching(sorted(tuples, sign), key, match)
|
|
31
|
+
|
|
32
|
+
after ? strictly_after(found, after, sign) : found
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# memtx starts a tree :all at the key, like :ge; a hash :all ignores the key.
|
|
38
|
+
def matcher(iterator)
|
|
39
|
+
return MATCHES[:ge] if iterator == :all && @index.type == 'tree'
|
|
40
|
+
|
|
41
|
+
MATCHES.fetch(iterator) { raise ArgumentError, "unsupported iterator #{iterator.inspect}" }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sign_for(iterator)
|
|
45
|
+
DESCENDING.include?(iterator) ? -1 : 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def matching(tuples, key, match)
|
|
49
|
+
tuples.select { |tuple| key.empty? || match.call(compare_key(tuple, key)) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def strictly_after(tuples, after, sign)
|
|
53
|
+
tuples.select { |tuple| Order.compare(position(tuple), position(after)) * sign == 1 }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def sorted(tuples, sign)
|
|
57
|
+
tuples.sort { |left, right| Order.compare(position(left), position(right)) * sign }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def compare_key(tuple, key)
|
|
61
|
+
Order.compare(@index.parts.first(key.size).map { |part| tuple[part.fieldno] }, key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def position(tuple)
|
|
65
|
+
(@index.parts + @primary.parts).map { |part| tuple[part.fieldno] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_hash!(key, iterator, after)
|
|
69
|
+
return unless @index.type == 'hash'
|
|
70
|
+
raise Violation.partial_hash_key(@index.parts.size, key.size) if partial?(key)
|
|
71
|
+
raise Violation.iterator(@index.name, @space_info.name) unless HASH_ITERATORS.include?(iterator)
|
|
72
|
+
raise Violation.pagination(@index.name, @space_info.name) if after
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def partial?(key)
|
|
76
|
+
!key.empty? && key.size != @index.parts.size
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# The sequence of a primary key field: fills nil from 1 and moves past explicit Integer values. Never
|
|
8
|
+
# restored by a rollback, as in Tarantool.
|
|
9
|
+
# @api private
|
|
10
|
+
class Sequence
|
|
11
|
+
def initialize(fieldno)
|
|
12
|
+
@fieldno = fieldno
|
|
13
|
+
@counter = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fill(tuple)
|
|
17
|
+
value = tuple[@fieldno]
|
|
18
|
+
if value.nil?
|
|
19
|
+
tuple[@fieldno] = @counter += 1
|
|
20
|
+
elsif value.is_a?(Integer)
|
|
21
|
+
@counter = [@counter, value].max
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# Rows of one space keyed by primary key, with the checks Tarantool makes on write. Values are deep-copied
|
|
8
|
+
# in and out, and a stored tuple is never changed in place, so a snapshot is a shallow copy of the rows.
|
|
9
|
+
# @api private
|
|
10
|
+
class Space
|
|
11
|
+
attr_reader :info, :layout
|
|
12
|
+
|
|
13
|
+
def initialize(info, sequence:)
|
|
14
|
+
@info = info
|
|
15
|
+
@layout = Storage::Box::Layout.new(info)
|
|
16
|
+
@primary = info.primary_index
|
|
17
|
+
@sequence = Sequence.new(@primary.parts.first.fieldno) if sequence
|
|
18
|
+
@required = required_fieldnos
|
|
19
|
+
@rows = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Storage::Box gets by a select with limit 1, so a partial key gives its first match.
|
|
23
|
+
def get(key)
|
|
24
|
+
return scan(index: nil, key:, iterator: :eq, after: nil).first unless key.size == @primary.parts.size
|
|
25
|
+
|
|
26
|
+
copy(@rows[copy(key)])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def insert(tuple)
|
|
30
|
+
tuple = copy(tuple)
|
|
31
|
+
@sequence&.fill(tuple)
|
|
32
|
+
|
|
33
|
+
store(tuple, replacing: nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update(key, operations)
|
|
37
|
+
key = exact(key)
|
|
38
|
+
current = @rows[key]
|
|
39
|
+
return unless current
|
|
40
|
+
|
|
41
|
+
updated = Operations.new(@info).apply(current, copy(operations))
|
|
42
|
+
raise Violation.primary_key_update(@info.name) unless key_of(updated, @primary) == key
|
|
43
|
+
|
|
44
|
+
store(updated, replacing: key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete(key)
|
|
48
|
+
copy(@rows.delete(exact(key)))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def scan(index:, key:, iterator:, after:)
|
|
52
|
+
Scan.new(@info, index_info(index)).call(@rows.values, key: copy(key), iterator:, after: copy(after))
|
|
53
|
+
.map { |tuple| copy(tuple) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def index?(name)
|
|
57
|
+
name.nil? || name.is_a?(Integer) || @info.indexes.key?(name.to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def snapshot
|
|
61
|
+
@rows.dup
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def restore(rows)
|
|
65
|
+
@rows = rows
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def index_info(name)
|
|
71
|
+
return @primary if name.nil?
|
|
72
|
+
|
|
73
|
+
found = name.is_a?(Integer) ? @info.indexes.each_value.find { |index| index.id == name } : by_name(name)
|
|
74
|
+
found || raise(Violation.unknown_index(name, @info.name))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def by_name(name)
|
|
78
|
+
@info.indexes[name.to_s]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def store(tuple, replacing:)
|
|
82
|
+
check_nils(tuple)
|
|
83
|
+
check_unique(tuple, replacing)
|
|
84
|
+
@rows.delete(replacing) if replacing
|
|
85
|
+
@rows[key_of(tuple, @primary)] = tuple
|
|
86
|
+
|
|
87
|
+
copy(tuple)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def check_nils(tuple)
|
|
91
|
+
@required.each do |fieldno|
|
|
92
|
+
raise Violation.nil_field(fieldno, @info.fields[fieldno]) if tuple[fieldno].nil?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_unique(tuple, replacing)
|
|
97
|
+
@info.indexes.each_value.select(&:unique).each do |index|
|
|
98
|
+
key = key_of(tuple, index)
|
|
99
|
+
next if key.include?(nil)
|
|
100
|
+
|
|
101
|
+
_, old = @rows.find { |row_key, row| row_key != replacing && key_of(row, index) == key }
|
|
102
|
+
next unless old
|
|
103
|
+
|
|
104
|
+
raise RecordNotUnique, duplicate_key_message(index, old, tuple)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def duplicate_key_message(index, old, tuple)
|
|
109
|
+
"Duplicate key exists in unique index \"#{index.name}\" in space \"#{@info.name}\" with old tuple - " \
|
|
110
|
+
"#{old.inspect} and new tuple - #{tuple.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def required_fieldnos
|
|
114
|
+
strict_parts = @info.indexes.each_value.flat_map(&:parts).reject(&:is_nullable).map(&:fieldno)
|
|
115
|
+
|
|
116
|
+
@info.fields.each_index.select do |fieldno|
|
|
117
|
+
field = @info.fields[fieldno]
|
|
118
|
+
strict_parts.include?(fieldno) || !(field.is_nullable || field.type == 'any')
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def exact(key)
|
|
123
|
+
raise Violation.exact_match(@primary.parts.size, key.size) unless key.size == @primary.parts.size
|
|
124
|
+
|
|
125
|
+
copy(key)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def key_of(tuple, index)
|
|
129
|
+
index.parts.map { |part| tuple[part.fieldno] }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Codec is @api private in tarantool-client, but released together with tarantool-model in this
|
|
133
|
+
# monorepo; only the wire round trip turns Symbols into Strings and rejects values Tarantool can't store.
|
|
134
|
+
def copy(value)
|
|
135
|
+
value && Client::Protocol::Codec::FACTORY.load(Client::Protocol::Codec::FACTORY.dump(value))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
class MemoryStorage
|
|
7
|
+
# What the server would reject, with its error code and text. Never leaves MemoryStorage: the mode turns it
|
|
8
|
+
# into the error class the application sees in production. `kind` is :space, :index, :nil_field or :server.
|
|
9
|
+
# @api private
|
|
10
|
+
class Violation < StandardError
|
|
11
|
+
attr_reader :kind, :code, :subject
|
|
12
|
+
|
|
13
|
+
def self.nil_field(fieldno, field)
|
|
14
|
+
new("Tuple field #{fieldno + 1} (#{field.name}) type does not match one required by operation: " \
|
|
15
|
+
"expected #{field.type}, got nil", kind: :nil_field, code: 23, subject: field.name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.primary_key_update(space)
|
|
19
|
+
new("Attempt to modify a tuple field which is part of primary index in space '#{space}'", code: 94)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.operand(operator, name)
|
|
23
|
+
new("Argument type in operation '#{operator}' on field '#{name}' does not match field type: " \
|
|
24
|
+
'expected a number', code: 26)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.unknown_field(name)
|
|
28
|
+
new("Field '#{name}' was not found in the tuple", code: 201)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.key_part_count(max, got)
|
|
32
|
+
new("Invalid key part count (expected [0..#{max}], got #{got})", code: 31)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.exact_match(expected, got)
|
|
36
|
+
new("Invalid key part count in an exact match (expected #{expected}, got #{got})", code: 19)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.partial_hash_key(expected, got)
|
|
40
|
+
new("HASH index does not support selects via a partial key (expected #{expected} parts, got #{got}). " \
|
|
41
|
+
'Please Consider changing index type to TREE.', code: 136)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.iterator(index, space)
|
|
45
|
+
new("Index '#{index}' (HASH) of space '#{space}' (memtx) does not support requested iterator type",
|
|
46
|
+
code: 112)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.pagination(index, space)
|
|
50
|
+
new("Index '#{index}' (HASH) of space '#{space}' (memtx) does not support pagination", code: 112)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.unknown_space(name)
|
|
54
|
+
new("space '#{name}' not found", kind: :space, subject: name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.unknown_index(index, space)
|
|
58
|
+
new("index '#{index}' not found in space #{space}", kind: :index, subject: index)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(message, kind: :server, code: nil, subject: nil)
|
|
62
|
+
super(message)
|
|
63
|
+
@kind = kind
|
|
64
|
+
@code = code
|
|
65
|
+
@subject = subject
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Testing
|
|
6
|
+
# In-memory storage for application tests, without a Tarantool server. Spaces are declared in
|
|
7
|
+
# tarantool-schema's ensure_space shape. `behaves_like: :box` keeps the Storage::Box contract, `:crud` the
|
|
8
|
+
# Storage::Crud one: what the server would reject is raised with the classes and codes the application sees
|
|
9
|
+
# in production. The README lists what it does not model.
|
|
10
|
+
class MemoryStorage
|
|
11
|
+
MODES = { box: BoxMode, crud: CrudMode }.freeze
|
|
12
|
+
private_constant :MODES
|
|
13
|
+
|
|
14
|
+
def self.from_definition(definition, **)
|
|
15
|
+
new(definition.to_h, **)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(spaces, behaves_like: :box)
|
|
19
|
+
@mode = MODES.fetch(behaves_like) do
|
|
20
|
+
raise ArgumentError, "behaves_like must be one of #{MODES.keys.join(', ')}, got #{behaves_like.inspect}"
|
|
21
|
+
end.new
|
|
22
|
+
Keys.distinct!(spaces.keys.map(&:to_s), 'MemoryStorage')
|
|
23
|
+
@spaces = spaces.to_h { |name, schema| [name.to_s, build_space(name, schema)] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def verify(space, attributes:, primary_key:)
|
|
27
|
+
@mode.verify(attributes:, primary_key:) { perform(space, :get, &:itself) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get(space, key)
|
|
31
|
+
single(space, :get) { |table| @mode.get(table, key) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def insert(space, attributes)
|
|
35
|
+
single(space, :insert) { |table| table.insert(@mode.to_tuple(table, attributes)) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update(space, key, operations)
|
|
39
|
+
single(space, :update) { |table| table.update(key, operations) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delete(space, key)
|
|
43
|
+
single(space, :delete) { |table| table.delete(key) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def select(space, index:, key:, iterator:, limit:, after:)
|
|
47
|
+
perform(space, :select) do |table|
|
|
48
|
+
tuples = @mode.scan(table, index:, key:, iterator:, after:)
|
|
49
|
+
|
|
50
|
+
(limit ? tuples.first(limit) : tuples).map { |tuple| @mode.row(table, tuple, single: false) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def count(space, index:, key:, iterator:)
|
|
55
|
+
@mode.count!
|
|
56
|
+
|
|
57
|
+
perform(space, :count) { |table| @mode.scan(table, index:, key:, iterator:, after: nil).size }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def supports_transactions? = @mode.supports_transactions?
|
|
61
|
+
|
|
62
|
+
# Writes apply at once; the rows are restored unless the block finishes normally. `isolation:` and
|
|
63
|
+
# `timeout:` are accepted and ignored. Sequences are not restored, as in Tarantool.
|
|
64
|
+
def transaction(**, &)
|
|
65
|
+
@mode.transaction!
|
|
66
|
+
raise Tarantool::TransactionError, 'nested transactions are not supported' if Transactions.handle(self)
|
|
67
|
+
|
|
68
|
+
snapshots = @spaces.transform_values(&:snapshot)
|
|
69
|
+
committed = false
|
|
70
|
+
Transactions.open(self, :memory, &).tap { committed = true }
|
|
71
|
+
ensure
|
|
72
|
+
snapshots&.each { |name, rows| @spaces.fetch(name).restore(rows) } unless committed
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def perform(space, operation)
|
|
78
|
+
yield @spaces.fetch(space.to_s) { raise Violation.unknown_space(space) }
|
|
79
|
+
rescue Violation => e
|
|
80
|
+
raise @mode.error(e, operation)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def single(space, operation)
|
|
84
|
+
perform(space, operation) do |table|
|
|
85
|
+
tuple = yield table
|
|
86
|
+
|
|
87
|
+
tuple && @mode.row(table, tuple, single: true)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_space(name, schema)
|
|
92
|
+
declaration = Declaration.new(name, schema)
|
|
93
|
+
|
|
94
|
+
Space.new(declaration.space_info, sequence: declaration.sequence && @mode.sequences?)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|