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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb88c7317a6b38d448f89c6ca24ad43f2e7c4b3da9a936795fae74a966b4873e
4
- data.tar.gz: c098a1157a2539596e6789d9717c883ed1d135f260d49b44d2018e1b42f53243
3
+ metadata.gz: dfebd43ac5f258976a8257dae17d23a26a607534c97c83303dc020f8fc7d3e82
4
+ data.tar.gz: 8aee6cf2bfc9c2e16dee2440ebfb4292f6458eeacb355ebe4a40bebdb5d8a43b
5
5
  SHA512:
6
- metadata.gz: 16b134ac4b0b0efe415455fcd09b4e314bc5ac91ed5e0408cac646298ce49a78d59ce86d74afe981edc9b0c4aacf2bb1ce395206912151e67c637555537d6303
7
- data.tar.gz: f9bf98de1e3c83bae3ccf399965e877887556683b8575fc54aa8ac75af2adf53412ee0af175d24dc4e589b01bf564652240cf00c0eab2ebabcbe574f6e515f06
6
+ metadata.gz: 8a4161e4bbf0f5593ef3d0a0b2cc8b2a2b49c5456466e4e1252e8ffccb8ec497a10c455fb2389da59f5dd0b30e4c0a5baaec3bd400a1d91174f7158c2520e9d3
7
+ data.tar.gz: 953f19659c46174dd935642bb8f99a4d846cc7dc892f9c7bbc992ee5e1272851a7d1526edfbec71ce1ec6581778a273ec86f5cd94cbee0b73086e6f253c0ef40
data/CHANGELOG.md CHANGED
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.3.0 (2026-10-17)
4
+
5
+ - `Tarantool::Model.default_storage`: a storage, or a Proc called once on first use, for models without one of
6
+ their own.
7
+ - Optimistic locking on the `locking_column` attribute (`lock_version` by default): update and destroy compare
8
+ the stored version with the record's and bump it, in a transaction on `Storage::Box`; `StaleObjectError`.
9
+ - Storages answer `supports_transactions?`.
10
+
11
+ ## 0.2.0 (2026-10-17)
12
+
13
+ - `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.
14
+ - `Testing::MemoryStorage` (`require 'tarantool/model/testing'`): in-memory storage for application tests that
15
+ keeps the `Storage::Box` or `Storage::Crud` contract, with spaces declared in tarantool-schema's format.
16
+ - Depends on `tarantool-client ~> 0.2.0`.
17
+
18
+ ## 0.1.0 (2026-10-16)
4
19
 
5
20
  First release.
6
21
 
data/README.md CHANGED
@@ -22,19 +22,14 @@ require 'tarantool/model'
22
22
  ```ruby
23
23
  pool = Tarantool::Client::Pool.new(['tcp://app:secret@db1:3301', 'tcp://app:secret@db2:3301'])
24
24
 
25
- module ApplicationModel
26
- def self.included(base)
27
- base.include Tarantool::Model
28
- base.storage = Tarantool::Model::Storage::Box.new(
29
- pool,
30
- read: { mode: :prefer_ro },
31
- write: { mode: :rw }
32
- )
33
- end
34
- end
25
+ Tarantool::Model.default_storage = Tarantool::Model::Storage::Box.new(
26
+ pool,
27
+ read: { mode: :prefer_ro },
28
+ write: { mode: :rw }
29
+ )
35
30
 
36
31
  class User
37
- include ApplicationModel
32
+ include Tarantool::Model
38
33
 
39
34
  attribute :id, :integer
40
35
  attribute :email, :string
@@ -44,6 +39,10 @@ class User
44
39
  end
45
40
  ```
46
41
 
42
+ `Tarantool::Model.default_storage` serves every model that sets no `storage` of its own; `User.storage = ...`
43
+ overrides it per model. A Proc assigned to it (`Tarantool::Model.default_storage = -> { build_storage }`) is
44
+ called once, on first use, so the connection is opened only when a model needs it.
45
+
47
46
  `Storage::Box` passes its `read:`/`write:` options verbatim to the client's read and write requests. The `Pool`
48
47
  routing `mode:` (symbols like `:prefer_ro`, `:rw`; requires a `Pool` as transport) is one example; `timeout:` works
49
48
  too. `Storage::Crud` takes no `read:`/`write:`: crud defaults are set on `Tarantool::Crud::Client.new(..., read:,
@@ -105,6 +104,9 @@ Callbacks: `before_validation`, `after_validation`, `before_save`, `around_save`
105
104
  `before_create`, `around_create`, `after_create`, `before_update`, `around_update`, `after_update`,
106
105
  `before_destroy`, `around_destroy`, `after_destroy`.
107
106
 
107
+ Models talk to Tarantool through the client given to their storage, so the client's `instrumenter:` and
108
+ `logger:` report every read and write; see the tarantool-client README.
109
+
108
110
  ### Counters
109
111
 
110
112
  ```ruby
@@ -116,6 +118,33 @@ user.decrement!(:age)
116
118
  Counters skip validations and callbacks. Only the counter attribute and the raw tuple are refreshed from the
117
119
  response; other unsaved changes on the record survive. Raises `RecordNotFound` if the row is gone.
118
120
 
121
+ ### Optimistic locking
122
+
123
+ ```ruby
124
+ class Account
125
+ include Tarantool::Model
126
+
127
+ attribute :id, :integer
128
+ attribute :balance, :decimal
129
+ attribute :lock_version, :integer, default: 0
130
+ end
131
+
132
+ Account.locking_column # => :lock_version
133
+ Account.locking_column = :version # another attribute, or nil to disable
134
+ ```
135
+
136
+ Locking is on when `locking_column` names a declared attribute; the space needs that field. A nil version is
137
+ stored as 0 on create. On update, the row is read again and its version compared with the record's (a value
138
+ assigned by hand, for example from a form field, counts); a different version raises `StaleObjectError`, and so
139
+ does a row changed underneath on destroy. The update sends the changed attributes plus the version incremented
140
+ by one, and the record picks the new version up. Only the version changed sends nothing.
141
+
142
+ On `Storage::Box` the check and the update run in one transaction (MVCC must be on, as for `transaction`), so a
143
+ concurrent commit on the same row between them surfaces as `StaleObjectError` too. Inside `Model.transaction` no
144
+ second transaction is opened; the check joins the open one. `Storage::Crud` has no transactions: the check runs
145
+ unguarded, and a write between the read and the update goes unnoticed. Counters (`increment!`, `decrement!`)
146
+ bypass locking and leave the version alone.
147
+
119
148
  ## Querying
120
149
 
121
150
  ```ruby
@@ -205,17 +234,95 @@ by key with crud `get` and does not use it. Make sure the index name matches the
205
234
  Keyset pagination with `after` requires a tuple, which `select` returns but single-row operations (`find`,
206
235
  `find_by`) do not. Calling `after` on a record loaded with `find` raises `ArgumentError`.
207
236
 
237
+ ## Testing
238
+
239
+ `Tarantool::Model::Testing::MemoryStorage` keeps rows in memory, so model specs run without a Tarantool server.
240
+ It is loaded only on request:
241
+
242
+ ```ruby
243
+ # spec/spec_helper.rb
244
+ require 'tarantool/model/testing'
245
+
246
+ USERS_SPACE = {
247
+ format: [{ name: :id, type: :unsigned }, { name: :email, type: :string },
248
+ { name: :age, type: :unsigned, is_nullable: true }],
249
+ indexes: [{ name: :primary, parts: [:id], sequence: true },
250
+ { name: :email, parts: [:email] },
251
+ { name: :age, parts: [:age], unique: false }]
252
+ }.freeze
253
+
254
+ RSpec.configure do |config|
255
+ config.before do
256
+ User.storage = Tarantool::Model::Testing::MemoryStorage.new({ users: USERS_SPACE })
257
+ end
258
+ end
259
+ ```
260
+
261
+ Build a new storage for every example; separate instances share nothing.
262
+
263
+ Spaces are declared like the `ensure_space` arguments of tarantool-schema: `format` entries are
264
+ `{ name:, type:, is_nullable: }`; `indexes` is an Array of `{ name:, parts:, unique:, type:, sequence: }` and the
265
+ first one is primary; `unique` defaults to true and `type` to `tree`. Parts are field names or
266
+ `{ field:, type:, is_nullable: }`. Unknown keys and types raise `ArgumentError`, and so do indexes Tarantool
267
+ refuses to create: a non-unique primary or `hash` index, a nullable part in either. Spaces are passed as one Hash,
268
+ `MemoryStorage.new({ users: SCHEMA, accounts: OTHER }, behaves_like: :crud)`; `behaves_like:` is the only keyword.
269
+ A tarantool-schema definition can be passed as is: `MemoryStorage.from_definition(definition)` duck-types
270
+ `definition.to_h`.
271
+
272
+ `behaves_like: :box` (default) keeps the `Storage::Box` contract; `behaves_like: :crud` keeps the
273
+ `Storage::Crud` one. What Tarantool would reject is raised with the class the application gets in production:
274
+
275
+ | Situation | `:box` | `:crud` |
276
+ |---|---|---|
277
+ | duplicate unique key | `RecordNotUnique` | `RecordNotUnique` |
278
+ | nil in a non-nullable field | `Tarantool::ServerError`, code 23 | `Tarantool::Crud::InsertError` / `UpdateError` |
279
+ | update of a primary key field | `Tarantool::ServerError`, code 94 | `Tarantool::Crud::UpdateError` |
280
+ | `+`/`-` on a non-number | `Tarantool::ServerError`, code 26 | `Tarantool::Crud::UpdateError` |
281
+ | key with more parts than the index | `Tarantool::ServerError`, code 31 | — |
282
+ | `get` by a key without every primary key part | first matching row | `Tarantool::Crud::GetError` |
283
+ | partial key on a multi-part hash index | `Tarantool::ServerError`, code 136 | — |
284
+ | unsupported iterator or `after` on a hash index | `Tarantool::ServerError`, code 112 | — |
285
+ | unknown space | `Tarantool::SchemaError` | `Tarantool::Crud::<Operation>Error` |
286
+ | `count` | `NotSupportedError` | counts |
287
+ | `transaction` | rolls back when the block raises | `NotSupportedError` |
288
+ | `supports_transactions?` | `true` | `false` |
289
+ | single-row results | with a tuple | `tuple` is nil |
290
+
291
+ `verify` (the schema check `Storage::Box` runs on first use) does nothing in `:crud` mode. A nil primary key gets
292
+ the next sequence value when the primary index has `sequence: true` (`:box` only). `transaction` accepts
293
+ `isolation:` and `timeout:` and ignores them. In `:crud` mode a missing `bucket_id` is filled from the primary key:
294
+ the same key always gets the same value, but not the bucket vshard would compute. Values, keys and `after` tuples
295
+ are copied through the client's msgpack codec, so they behave as on the wire: Symbols become Strings (incl. Hash
296
+ keys in any/map fields), and changing a returned value never changes the store.
297
+
298
+ Not modelled:
299
+
300
+ - value types: only nullability is checked, so a String in an `unsigned` field is stored;
301
+ - update operators other than `=`, `+` and `-` (`NotSupportedError`);
302
+ - MVCC and concurrency: writes are visible at once, and a transaction is a snapshot restored on error;
303
+ - Strings compare bytewise like Tarantool's default collation; values of different classes in one index raise
304
+ `ArgumentError`;
305
+ - a `hash` index walks in key order, Tarantool's order is unspecified;
306
+ - `get`, `update` and `delete` match primary keys by Ruby equality, so `[1.0]` does not find a row stored as `[1]`,
307
+ while Tarantool does;
308
+ - in `:crud` mode, a condition through a name that is a field but not an index: crud filters by that field,
309
+ MemoryStorage returns nothing;
310
+ - JSON path parts, `rtree` and `bitset` indexes (`ArgumentError`);
311
+ - thread safety.
312
+
208
313
  ## Rails
209
314
 
210
315
  `to_param` joins a composite key with `-` (e.g. `"7-1"`) for route helpers. `as_json` works through
211
316
  `ActiveModel::Serializers::JSON`. `form_with` works with any persisted model.
212
317
 
213
- There is no Railtie. Configure storage in an initializer:
318
+ `tarantool-rails` configures the storage from `config/tarantool.yml`. Without it, set
319
+ `Tarantool::Model.default_storage` in an initializer:
214
320
 
215
321
  ```ruby
216
322
  # config/initializers/tarantool.rb
217
323
  pool = Tarantool::Client::Pool.new(Rails.application.config.tarantool_uris)
218
- User.storage = Tarantool::Model::Storage::Box.new(pool, read: { mode: :prefer_ro }, write: { mode: :rw })
324
+ Tarantool::Model.default_storage =
325
+ Tarantool::Model::Storage::Box.new(pool, read: { mode: :prefer_ro }, write: { mode: :rw })
219
326
  ```
220
327
 
221
328
  ## Errors
@@ -229,6 +336,7 @@ Tarantool::Model::Error
229
336
  ├── RecordInvalid # #record
230
337
  ├── RecordNotSaved # #record; a before callback aborted
231
338
  ├── RecordNotUnique # duplicate primary or unique secondary key
339
+ ├── StaleObjectError # #record, #attempted_action; the row's version changed since the record was loaded
232
340
  ├── SchemaMismatchError # attribute or primary key does not match the space format
233
341
  ├── NotSupportedError # operation not available for this storage (e.g. count on Storage::Box)
234
342
  └── TransactionMismatchError # write through a storage that has no open transaction in the current fiber
@@ -260,8 +368,8 @@ TARANTOOL_MODEL_CRUD_URI=tcp://app:secret@localhost:3332 bundle exec rspec --tag
260
368
 
261
369
  ## Public API
262
370
 
263
- `Tarantool::Model`, `Storage::Box`, `Storage::Crud`, `Types::UUID`, `Types::Interval`, `Types::Binary`,
264
- `Relation`, and the error classes above.
371
+ `Tarantool::Model`, `Storage::Box`, `Storage::Crud`, `Testing::MemoryStorage`, `Types::UUID`, `Types::Interval`,
372
+ `Types::Binary`, `Relation`, and the error classes above.
265
373
 
266
374
  Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
267
375
  and may change in any release.
@@ -35,6 +35,17 @@ module Tarantool
35
35
  end
36
36
  end
37
37
 
38
+ # The row's locking column changed since the record was loaded.
39
+ class StaleObjectError < Error
40
+ attr_reader :record, :attempted_action
41
+
42
+ def initialize(record, attempted_action)
43
+ @record = record
44
+ @attempted_action = attempted_action
45
+ super("Attempted to #{attempted_action} a stale #{record.class.name} with key #{record.to_key.inspect}")
46
+ end
47
+ end
48
+
38
49
  class RecordNotUnique < Error; end
39
50
  class SchemaMismatchError < Error; end
40
51
  class TransactionMismatchError < Error; end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Model
5
+ # Optimistic locking on the attribute named by locking_column, when the model declares it. An update compares
6
+ # the stored version with the record's and bumps it, inside a transaction on storages that have them, so a
7
+ # concurrent commit on the same row surfaces as StaleObjectError instead of being overwritten.
8
+ module Locking
9
+ extend ActiveSupport::Concern
10
+
11
+ ER_TRANSACTION_CONFLICT = 97
12
+ private_constant :ER_TRANSACTION_CONFLICT
13
+
14
+ included do
15
+ class_attribute :locking_column, instance_accessor: false, default: :lock_version
16
+ end
17
+
18
+ class_methods do
19
+ def locking_enabled?
20
+ !locking_column.nil? && attribute_names.include?(locking_column.to_s)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def create_row
27
+ public_send("#{locking_column}=", 0) if locking_enabled? && public_send(locking_column).nil?
28
+
29
+ super
30
+ end
31
+
32
+ def update_pending?
33
+ return super unless locking_enabled?
34
+
35
+ changes.except(locking_column.to_s).any?
36
+ end
37
+
38
+ def updated_row
39
+ return super unless locking_enabled?
40
+
41
+ atomically(:update) do
42
+ locked_row(:update) || raise(RecordNotFound.new(self.class, key_in_database))
43
+
44
+ write_row(locked_operations)
45
+ end
46
+ end
47
+
48
+ def delete_row
49
+ return super unless locking_enabled? && persisted?
50
+
51
+ atomically(:destroy) do
52
+ locked_row(:destroy)
53
+
54
+ super()
55
+ end
56
+ end
57
+
58
+ def locked_operations
59
+ column = locking_column.to_s
60
+ operations = update_operations.reject { |(_operator, name, _value)| name == column }
61
+
62
+ operations << ['=', column, public_send(locking_column).to_i + 1]
63
+ end
64
+
65
+ # The row, or nil when it is gone; a version other than the record's raises.
66
+ def locked_row(action)
67
+ row = space_storage.get(space_name, key_in_database)
68
+ raise StaleObjectError.new(self, action) if row && row.attributes[locking_column] != public_send(locking_column)
69
+
70
+ row
71
+ end
72
+
73
+ # Inside an open Model.transaction the check joins it; without transactions (crud) it runs unguarded.
74
+ def atomically(action, &)
75
+ storage = writing_storage
76
+ return yield if Transactions.handle(storage) || !storage.supports_transactions?
77
+
78
+ storage.transaction(&)
79
+ rescue Tarantool::ServerError => e
80
+ raise StaleObjectError.new(self, action) if e.code == ER_TRANSACTION_CONFLICT
81
+
82
+ raise
83
+ end
84
+
85
+ def locking_column = self.class.locking_column
86
+
87
+ def locking_enabled? = self.class.locking_enabled?
88
+ end
89
+ end
90
+ end
@@ -80,15 +80,23 @@ module Tarantool
80
80
  raise ArgumentError, "the primary key of a persisted #{self.class.name} cannot change" if primary_key_changed?
81
81
 
82
82
  run_callbacks(:update) do
83
- written(updated_row) if changed?
83
+ written(updated_row) if update_pending?
84
84
 
85
85
  true
86
86
  end
87
87
  end
88
88
 
89
+ def update_pending? = changed?
90
+
89
91
  def updated_row
90
- operations = changes.map { |name, (_was, value)| ['=', name, value] }
92
+ write_row(update_operations)
93
+ end
94
+
95
+ def update_operations
96
+ changes.map { |name, (_was, value)| ['=', name, value] }
97
+ end
91
98
 
99
+ def write_row(operations)
92
100
  writing_storage.update(space_name, key_in_database, operations) ||
93
101
  raise(RecordNotFound.new(self.class, key_in_database))
94
102
  end
@@ -7,13 +7,22 @@ module Tarantool
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- class_attribute :storage, instance_accessor: false
11
10
  class_attribute :primary_key, instance_accessor: false, default: :id
12
11
  # @api private
12
+ class_attribute :_storage, instance_accessor: false
13
+ # @api private
13
14
  class_attribute :_space_name, instance_accessor: false
14
15
  end
15
16
 
16
17
  class_methods do
18
+ def storage
19
+ _storage || Tarantool::Model.default_storage
20
+ end
21
+
22
+ def storage=(storage)
23
+ self._storage = storage
24
+ end
25
+
17
26
  def space_name
18
27
  _space_name || model_name.plural
19
28
  end
@@ -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
@@ -67,6 +54,8 @@ module Tarantool
67
54
  raise NotSupportedError, 'Storage::Box does not count'
68
55
  end
69
56
 
57
+ def supports_transactions? = true
58
+
70
59
  def transaction(**options, &block)
71
60
  @caller.transaction(**options) do |stream|
72
61
  Transactions.open(self, stream, &block)
@@ -91,8 +80,8 @@ module Tarantool
91
80
  Transactions.handle(self) ? options.except(:mode) : options
92
81
  end
93
82
 
94
- def layout(space, options)
95
- Layout.new(client.space_info(space, **options.slice(:mode, :timeout)))
83
+ def row(found)
84
+ found && Row.new(attributes: found.attributes, tuple: found.tuple)
96
85
  end
97
86
 
98
87
  def unique
@@ -48,6 +48,8 @@ module Tarantool
48
48
  @crud.space(space).count(conditions(index, key, iterator))
49
49
  end
50
50
 
51
+ def supports_transactions? = false
52
+
51
53
  def transaction(**)
52
54
  raise NotSupportedError, 'crud has no interactive transactions'
53
55
  end
@@ -0,0 +1,49 @@
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 supports_transactions? = true
19
+
20
+ def get(space, key)
21
+ space.get(key)
22
+ end
23
+
24
+ def to_tuple(space, attributes)
25
+ space.info.to_tuple(attributes)
26
+ end
27
+
28
+ def row(space, tuple, **)
29
+ Row.new(attributes: space.info.to_hash(tuple), tuple:)
30
+ end
31
+
32
+ def count!
33
+ raise NotSupportedError, 'MemoryStorage behaves like Storage::Box, which does not count'
34
+ end
35
+
36
+ def scan(space, **)
37
+ space.scan(**)
38
+ end
39
+
40
+ def error(violation, _operation)
41
+ return Tarantool::SchemaError.new(violation.message) if %i[space index].include?(violation.kind)
42
+
43
+ Tarantool::ServerError.new(violation.message, code: violation.code)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,81 @@
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 supports_transactions? = false
27
+
28
+ def get(space, key)
29
+ parts = space.info.primary_index.parts.size
30
+ raise Violation.exact_match(parts, key.size) unless key.size == parts
31
+
32
+ space.get(key)
33
+ end
34
+
35
+ def to_tuple(space, attributes)
36
+ tuple = space.info.to_tuple(attributes)
37
+ fieldno = space.info.fields.index { |field| field.name == BUCKET_ID }
38
+ tuple[fieldno] ||= bucket(space, tuple) if fieldno
39
+
40
+ tuple
41
+ end
42
+
43
+ def row(space, tuple, single:)
44
+ Row.new(attributes: space.info.to_hash(tuple), tuple: single ? nil : tuple)
45
+ end
46
+
47
+ # Without a condition crud scans the primary index. With one, crud reads a name that is not an index as a
48
+ # field name and filters by that field; this returns nothing instead, as crud does for a name that is
49
+ # neither.
50
+ def scan(space, index:, key:, iterator:, after:)
51
+ return space.scan(index: nil, key: [], iterator: :all, after:) if iterator == :all || key.empty?
52
+ return [] unless space.index?(index)
53
+
54
+ space.scan(index:, key:, iterator:, after:)
55
+ end
56
+
57
+ def error(violation, operation)
58
+ raise Error, "tarantool-crud is not loaded; require 'tarantool/crud'" unless defined?(Tarantool::Crud)
59
+
60
+ Tarantool::Crud.const_get("#{operation.capitalize}Error").new(message(violation, operation))
61
+ end
62
+
63
+ private
64
+
65
+ def bucket(space, tuple)
66
+ key = space.info.primary_index.parts.map { |part| tuple[part.fieldno] }
67
+
68
+ (Zlib.crc32(Marshal.dump(key)) % BUCKET_COUNT) + 1
69
+ end
70
+
71
+ def message(violation, operation)
72
+ return %(Space "#{violation.subject}" doesn't exist) if violation.kind == :space
73
+ return format(FLATTEN, violation.subject) if violation.kind == :nil_field && operation == :insert
74
+
75
+ "Failed to #{operation}: #{violation.message}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end