tarantool-model 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb88c7317a6b38d448f89c6ca24ad43f2e7c4b3da9a936795fae74a966b4873e
4
- data.tar.gz: c098a1157a2539596e6789d9717c883ed1d135f260d49b44d2018e1b42f53243
3
+ metadata.gz: 3b70f00434a73dd90e2c7fcd3b6fa9e73e3e9f2a4809da099d22301d6e64f024
4
+ data.tar.gz: cdd55a36929dd8d4a3cb8c10ff4e39e90c1aa304b2ccea431db3354caf80830b
5
5
  SHA512:
6
- metadata.gz: 16b134ac4b0b0efe415455fcd09b4e314bc5ac91ed5e0408cac646298ce49a78d59ce86d74afe981edc9b0c4aacf2bb1ce395206912151e67c637555537d6303
7
- data.tar.gz: f9bf98de1e3c83bae3ccf399965e877887556683b8575fc54aa8ac75af2adf53412ee0af175d24dc4e589b01bf564652240cf00c0eab2ebabcbe574f6e515f06
6
+ metadata.gz: 0f63d44c5c2cf9d9ac311efcfe0ee7a46d7819ce4c5681c9200eaaa0116aad14b810b36acdc39480c28cc2fbc9679cedace62acb02cb41da3ee55965d5928ea2
7
+ data.tar.gz: 912d7c7254e2c5e9aa4d8e045da95b04c18570eff8b0dd3ee4b8b088ce9837917e11776bfb350e86c648467f7bfba1d7af8627f0bc7dd63701e2ad8d573f1c99
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.2.0 (2026-10-17)
4
+
5
+ - `Storage::Box` converts rows with tarantool-client (`as: :row`), by the format of the schema each request ran against, and no longer looks up `space_info` before every operation.
6
+ - `Testing::MemoryStorage` (`require 'tarantool/model/testing'`): in-memory storage for application tests that
7
+ keeps the `Storage::Box` or `Storage::Crud` contract, with spaces declared in tarantool-schema's format.
8
+ - Depends on `tarantool-client ~> 0.2.0`.
9
+
10
+ ## 0.1.0 (2026-10-16)
4
11
 
5
12
  First release.
6
13
 
data/README.md CHANGED
@@ -105,6 +105,9 @@ Callbacks: `before_validation`, `after_validation`, `before_save`, `around_save`
105
105
  `before_create`, `around_create`, `after_create`, `before_update`, `around_update`, `after_update`,
106
106
  `before_destroy`, `around_destroy`, `after_destroy`.
107
107
 
108
+ Models talk to Tarantool through the client given to their storage, so the client's `instrumenter:` and
109
+ `logger:` report every read and write; see the tarantool-client README.
110
+
108
111
  ### Counters
109
112
 
110
113
  ```ruby
@@ -205,6 +208,81 @@ by key with crud `get` and does not use it. Make sure the index name matches the
205
208
  Keyset pagination with `after` requires a tuple, which `select` returns but single-row operations (`find`,
206
209
  `find_by`) do not. Calling `after` on a record loaded with `find` raises `ArgumentError`.
207
210
 
211
+ ## Testing
212
+
213
+ `Tarantool::Model::Testing::MemoryStorage` keeps rows in memory, so model specs run without a Tarantool server.
214
+ It is loaded only on request:
215
+
216
+ ```ruby
217
+ # spec/spec_helper.rb
218
+ require 'tarantool/model/testing'
219
+
220
+ USERS_SPACE = {
221
+ format: [{ name: :id, type: :unsigned }, { name: :email, type: :string },
222
+ { name: :age, type: :unsigned, is_nullable: true }],
223
+ indexes: [{ name: :primary, parts: [:id], sequence: true },
224
+ { name: :email, parts: [:email] },
225
+ { name: :age, parts: [:age], unique: false }]
226
+ }.freeze
227
+
228
+ RSpec.configure do |config|
229
+ config.before do
230
+ User.storage = Tarantool::Model::Testing::MemoryStorage.new({ users: USERS_SPACE })
231
+ end
232
+ end
233
+ ```
234
+
235
+ Build a new storage for every example; separate instances share nothing.
236
+
237
+ Spaces are declared like the `ensure_space` arguments of tarantool-schema: `format` entries are
238
+ `{ name:, type:, is_nullable: }`; `indexes` is an Array of `{ name:, parts:, unique:, type:, sequence: }` and the
239
+ first one is primary; `unique` defaults to true and `type` to `tree`. Parts are field names or
240
+ `{ field:, type:, is_nullable: }`. Unknown keys and types raise `ArgumentError`, and so do indexes Tarantool
241
+ refuses to create: a non-unique primary or `hash` index, a nullable part in either. Spaces are passed as one Hash,
242
+ `MemoryStorage.new({ users: SCHEMA, accounts: OTHER }, behaves_like: :crud)`; `behaves_like:` is the only keyword.
243
+ A tarantool-schema definition can be passed as is: `MemoryStorage.from_definition(definition)` duck-types
244
+ `definition.to_h`.
245
+
246
+ `behaves_like: :box` (default) keeps the `Storage::Box` contract; `behaves_like: :crud` keeps the
247
+ `Storage::Crud` one. What Tarantool would reject is raised with the class the application gets in production:
248
+
249
+ | Situation | `:box` | `:crud` |
250
+ |---|---|---|
251
+ | duplicate unique key | `RecordNotUnique` | `RecordNotUnique` |
252
+ | nil in a non-nullable field | `Tarantool::ServerError`, code 23 | `Tarantool::Crud::InsertError` / `UpdateError` |
253
+ | update of a primary key field | `Tarantool::ServerError`, code 94 | `Tarantool::Crud::UpdateError` |
254
+ | `+`/`-` on a non-number | `Tarantool::ServerError`, code 26 | `Tarantool::Crud::UpdateError` |
255
+ | key with more parts than the index | `Tarantool::ServerError`, code 31 | — |
256
+ | `get` by a key without every primary key part | first matching row | `Tarantool::Crud::GetError` |
257
+ | partial key on a multi-part hash index | `Tarantool::ServerError`, code 136 | — |
258
+ | unsupported iterator or `after` on a hash index | `Tarantool::ServerError`, code 112 | — |
259
+ | unknown space | `Tarantool::SchemaError` | `Tarantool::Crud::<Operation>Error` |
260
+ | `count` | `NotSupportedError` | counts |
261
+ | `transaction` | rolls back when the block raises | `NotSupportedError` |
262
+ | single-row results | with a tuple | `tuple` is nil |
263
+
264
+ `verify` (the schema check `Storage::Box` runs on first use) does nothing in `:crud` mode. A nil primary key gets
265
+ the next sequence value when the primary index has `sequence: true` (`:box` only). `transaction` accepts
266
+ `isolation:` and `timeout:` and ignores them. In `:crud` mode a missing `bucket_id` is filled from the primary key:
267
+ the same key always gets the same value, but not the bucket vshard would compute. Values, keys and `after` tuples
268
+ are copied through the client's msgpack codec, so they behave as on the wire: Symbols become Strings (incl. Hash
269
+ keys in any/map fields), and changing a returned value never changes the store.
270
+
271
+ Not modelled:
272
+
273
+ - value types: only nullability is checked, so a String in an `unsigned` field is stored;
274
+ - update operators other than `=`, `+` and `-` (`NotSupportedError`);
275
+ - MVCC and concurrency: writes are visible at once, and a transaction is a snapshot restored on error;
276
+ - Strings compare bytewise like Tarantool's default collation; values of different classes in one index raise
277
+ `ArgumentError`;
278
+ - a `hash` index walks in key order, Tarantool's order is unspecified;
279
+ - `get`, `update` and `delete` match primary keys by Ruby equality, so `[1.0]` does not find a row stored as `[1]`,
280
+ while Tarantool does;
281
+ - in `:crud` mode, a condition through a name that is a field but not an index: crud filters by that field,
282
+ MemoryStorage returns nothing;
283
+ - JSON path parts, `rtree` and `bitset` indexes (`ArgumentError`);
284
+ - thread safety.
285
+
208
286
  ## Rails
209
287
 
210
288
  `to_param` joins a composite key with `-` (e.g. `"7-1"`) for route helpers. `as_json` works through
@@ -260,8 +338,8 @@ TARANTOOL_MODEL_CRUD_URI=tcp://app:secret@localhost:3332 bundle exec rspec --tag
260
338
 
261
339
  ## Public API
262
340
 
263
- `Tarantool::Model`, `Storage::Box`, `Storage::Crud`, `Types::UUID`, `Types::Interval`, `Types::Binary`,
264
- `Relation`, and the error classes above.
341
+ `Tarantool::Model`, `Storage::Box`, `Storage::Crud`, `Testing::MemoryStorage`, `Types::UUID`, `Types::Interval`,
342
+ `Types::Binary`, `Relation`, and the error classes above.
265
343
 
266
344
  Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
267
345
  and may change in any release.
@@ -4,8 +4,7 @@ module Tarantool
4
4
  module Model
5
5
  module Storage
6
6
  class Box
7
- # Field order of a space: converts attribute Hashes to tuples and back, and checks a model against the
8
- # format. Fields the model does not declare go out as nil and are ignored on read.
7
+ # Checks a model against the space format.
9
8
  # @api private
10
9
  class Layout
11
10
  def initialize(space_info)
@@ -13,14 +12,6 @@ module Tarantool
13
12
  @names = space_info.fields.map { |field| field.name.to_sym }
14
13
  end
15
14
 
16
- def to_tuple(attributes)
17
- @names.map { |name| attributes[name] }
18
- end
19
-
20
- def to_row(tuple)
21
- Row.new(attributes: @names.zip(tuple).to_h, tuple:)
22
- end
23
-
24
15
  def verify!(attributes:, primary_key:)
25
16
  missing = attributes - @names
26
17
  raise SchemaMismatchError, "space #{@space_info.name} has no field #{missing.join(', ')}" if missing.any?
@@ -4,9 +4,9 @@ module Tarantool
4
4
  module Model
5
5
  module Storage
6
6
  # Storage over a Tarantool::Client or Client::Pool. `read` options go to get and select, `write` options
7
- # to insert, update and delete, verbatim. The format comes from the client's schema on every call, so
8
- # alter and reconnect are picked up without a cache here. Inside Model.transaction operations, including
9
- # space_info, go through the open stream.
7
+ # to insert, update and delete, verbatim. The client converts tuples by the format of the schema each request
8
+ # ran against, so alter and reconnect are picked up without a cache here. Inside Model.transaction operations,
9
+ # including space_info, go through the open stream.
10
10
  class Box
11
11
  ER_TUPLE_FOUND = 3
12
12
  private_constant :ER_TUPLE_FOUND
@@ -22,43 +22,30 @@ module Tarantool
22
22
  end
23
23
 
24
24
  def verify(space, attributes:, primary_key:)
25
- layout(space, read).verify!(attributes:, primary_key:)
25
+ Layout.new(client.space_info(space, **read.slice(:mode, :timeout))).verify!(attributes:, primary_key:)
26
26
  end
27
27
 
28
28
  def get(space, key)
29
- layout = layout(space, read)
30
- tuple = client.select(space, key, limit: 1, **read).first
31
-
32
- tuple && layout.to_row(tuple)
29
+ row(client.select(space, key, limit: 1, as: :row, **read).first)
33
30
  end
34
31
 
35
32
  def insert(space, attributes)
36
- layout = layout(space, write)
37
- tuple = unique { client.insert(space, layout.to_tuple(attributes), **write) }
38
-
39
- layout.to_row(tuple)
33
+ row(unique { client.insert(space, attributes, as: :row, **write) })
40
34
  end
41
35
 
42
36
  def update(space, key, operations)
43
- layout = layout(space, write)
44
- tuple = unique { client.update(space, key, operations, **write) }
45
-
46
- tuple && layout.to_row(tuple)
37
+ row(unique { client.update(space, key, operations, as: :row, **write) })
47
38
  end
48
39
 
49
40
  def delete(space, key)
50
- layout = layout(space, write)
51
- tuple = client.delete(space, key, **write)
52
-
53
- tuple && layout.to_row(tuple)
41
+ row(client.delete(space, key, as: :row, **write))
54
42
  end
55
43
 
56
44
  def select(space, index:, key:, iterator:, limit:, after:)
57
- layout = layout(space, read)
58
- options = { index: index || 0, iterator:, after:, **read }
45
+ options = { index: index || 0, iterator:, after:, as: :row, **read }
59
46
  options[:limit] = limit if limit
60
47
 
61
- client.select(space, key, **options).map { |tuple| layout.to_row(tuple) }
48
+ client.select(space, key, **options).map { |found| row(found) }
62
49
  end
63
50
 
64
51
  # IPROTO has no count request, and calling box.space.X.index.Y:count needs execute grants
@@ -91,8 +78,8 @@ module Tarantool
91
78
  Transactions.handle(self) ? options.except(:mode) : options
92
79
  end
93
80
 
94
- def layout(space, options)
95
- Layout.new(client.space_info(space, **options.slice(:mode, :timeout)))
81
+ def row(found)
82
+ found && Row.new(attributes: found.attributes, tuple: found.tuple)
96
83
  end
97
84
 
98
85
  def unique
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Model
5
+ module Testing
6
+ class MemoryStorage
7
+ # Storage::Box behaviour: schema checks, a tuple on every row, transactions, no count, server errors.
8
+ # @api private
9
+ class BoxMode
10
+ def verify(attributes:, primary_key:)
11
+ yield.layout.verify!(attributes:, primary_key:)
12
+ end
13
+
14
+ def sequences? = true
15
+
16
+ def transaction! = nil
17
+
18
+ def get(space, key)
19
+ space.get(key)
20
+ end
21
+
22
+ def to_tuple(space, attributes)
23
+ space.info.to_tuple(attributes)
24
+ end
25
+
26
+ def row(space, tuple, **)
27
+ Row.new(attributes: space.info.to_hash(tuple), tuple:)
28
+ end
29
+
30
+ def count!
31
+ raise NotSupportedError, 'MemoryStorage behaves like Storage::Box, which does not count'
32
+ end
33
+
34
+ def scan(space, **)
35
+ space.scan(**)
36
+ end
37
+
38
+ def error(violation, _operation)
39
+ return Tarantool::SchemaError.new(violation.message) if %i[space index].include?(violation.kind)
40
+
41
+ Tarantool::ServerError.new(violation.message, code: violation.code)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Model
5
+ module Testing
6
+ class MemoryStorage
7
+ # Storage::Crud behaviour: no schema check, count, no transactions, no tuple on single-row results, no
8
+ # sequences, bucket_id filled in, and the tarantool-crud error class of each operation.
9
+ # @api private
10
+ class CrudMode
11
+ BUCKET_ID = 'bucket_id'
12
+ BUCKET_COUNT = 30_000
13
+ FLATTEN = 'Failed to flatten object: FlattenError: Object is specified in bad format: FlattenError: ' \
14
+ 'Field "%s" isn\'t nullable (set skip_nullability_check_on_flatten option to true to skip check)'
15
+
16
+ def verify(**) = nil
17
+
18
+ def count! = nil
19
+
20
+ def transaction!
21
+ raise NotSupportedError, 'crud has no interactive transactions'
22
+ end
23
+
24
+ def sequences? = false
25
+
26
+ def get(space, key)
27
+ parts = space.info.primary_index.parts.size
28
+ raise Violation.exact_match(parts, key.size) unless key.size == parts
29
+
30
+ space.get(key)
31
+ end
32
+
33
+ def to_tuple(space, attributes)
34
+ tuple = space.info.to_tuple(attributes)
35
+ fieldno = space.info.fields.index { |field| field.name == BUCKET_ID }
36
+ tuple[fieldno] ||= bucket(space, tuple) if fieldno
37
+
38
+ tuple
39
+ end
40
+
41
+ def row(space, tuple, single:)
42
+ Row.new(attributes: space.info.to_hash(tuple), tuple: single ? nil : tuple)
43
+ end
44
+
45
+ # Without a condition crud scans the primary index. With one, crud reads a name that is not an index as a
46
+ # field name and filters by that field; this returns nothing instead, as crud does for a name that is
47
+ # neither.
48
+ def scan(space, index:, key:, iterator:, after:)
49
+ return space.scan(index: nil, key: [], iterator: :all, after:) if iterator == :all || key.empty?
50
+ return [] unless space.index?(index)
51
+
52
+ space.scan(index:, key:, iterator:, after:)
53
+ end
54
+
55
+ def error(violation, operation)
56
+ raise Error, "tarantool-crud is not loaded; require 'tarantool/crud'" unless defined?(Tarantool::Crud)
57
+
58
+ Tarantool::Crud.const_get("#{operation.capitalize}Error").new(message(violation, operation))
59
+ end
60
+
61
+ private
62
+
63
+ def bucket(space, tuple)
64
+ key = space.info.primary_index.parts.map { |part| tuple[part.fieldno] }
65
+
66
+ (Zlib.crc32(Marshal.dump(key)) % BUCKET_COUNT) + 1
67
+ end
68
+
69
+ def message(violation, operation)
70
+ return %(Space "#{violation.subject}" doesn't exist) if violation.kind == :space
71
+ return format(FLATTEN, violation.subject) if violation.kind == :nil_field && operation == :insert
72
+
73
+ "Failed to #{operation}: #{violation.message}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -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,97 @@
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
+ # Writes apply at once; the rows are restored unless the block finishes normally. `isolation:` and
61
+ # `timeout:` are accepted and ignored. Sequences are not restored, as in Tarantool.
62
+ def transaction(**, &)
63
+ @mode.transaction!
64
+ raise Tarantool::TransactionError, 'nested transactions are not supported' if Transactions.handle(self)
65
+
66
+ snapshots = @spaces.transform_values(&:snapshot)
67
+ committed = false
68
+ Transactions.open(self, :memory, &).tap { committed = true }
69
+ ensure
70
+ snapshots&.each { |name, rows| @spaces.fetch(name).restore(rows) } unless committed
71
+ end
72
+
73
+ private
74
+
75
+ def perform(space, operation)
76
+ yield @spaces.fetch(space.to_s) { raise Violation.unknown_space(space) }
77
+ rescue Violation => e
78
+ raise @mode.error(e, operation)
79
+ end
80
+
81
+ def single(space, operation)
82
+ perform(space, operation) do |table|
83
+ tuple = yield table
84
+
85
+ tuple && @mode.row(table, tuple, single: true)
86
+ end
87
+ end
88
+
89
+ def build_space(name, schema)
90
+ declaration = Declaration.new(name, schema)
91
+
92
+ Space.new(declaration.space_info, sequence: declaration.sequence && @mode.sequences?)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tarantool/model'
4
+ require 'zlib'
5
+
6
+ require_relative 'testing/memory_storage/keys'
7
+ require_relative 'testing/memory_storage/violation'
8
+ require_relative 'testing/memory_storage/index_declaration'
9
+ require_relative 'testing/memory_storage/declaration'
10
+ require_relative 'testing/memory_storage/operations'
11
+ require_relative 'testing/memory_storage/order'
12
+ require_relative 'testing/memory_storage/scan'
13
+ require_relative 'testing/memory_storage/sequence'
14
+ require_relative 'testing/memory_storage/space'
15
+ require_relative 'testing/memory_storage/box_mode'
16
+ require_relative 'testing/memory_storage/crud_mode'
17
+ require_relative 'testing/memory_storage'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tarantool
4
4
  module Model
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tarantool-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Starlexxx
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-16 00:00:00.000000000 Z
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -36,14 +36,14 @@ dependencies:
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.1.0
39
+ version: 0.2.0
40
40
  type: :runtime
41
41
  prerelease: false
42
42
  version_requirements: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: 0.1.0
46
+ version: 0.2.0
47
47
  description: Attributes, validations, callbacks, dirty tracking and index-based queries
48
48
  with keyset pagination over Tarantool 2.11+ and 3.x spaces, through tarantool-client
49
49
  or tarantool-crud.
@@ -69,6 +69,19 @@ files:
69
69
  - lib/tarantool/model/storage/box.rb
70
70
  - lib/tarantool/model/storage/box/layout.rb
71
71
  - lib/tarantool/model/storage/crud.rb
72
+ - lib/tarantool/model/testing.rb
73
+ - lib/tarantool/model/testing/memory_storage.rb
74
+ - lib/tarantool/model/testing/memory_storage/box_mode.rb
75
+ - lib/tarantool/model/testing/memory_storage/crud_mode.rb
76
+ - lib/tarantool/model/testing/memory_storage/declaration.rb
77
+ - lib/tarantool/model/testing/memory_storage/index_declaration.rb
78
+ - lib/tarantool/model/testing/memory_storage/keys.rb
79
+ - lib/tarantool/model/testing/memory_storage/operations.rb
80
+ - lib/tarantool/model/testing/memory_storage/order.rb
81
+ - lib/tarantool/model/testing/memory_storage/scan.rb
82
+ - lib/tarantool/model/testing/memory_storage/sequence.rb
83
+ - lib/tarantool/model/testing/memory_storage/space.rb
84
+ - lib/tarantool/model/testing/memory_storage/violation.rb
72
85
  - lib/tarantool/model/transactional.rb
73
86
  - lib/tarantool/model/transactions.rb
74
87
  - lib/tarantool/model/types/binary.rb