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