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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dfebd43ac5f258976a8257dae17d23a26a607534c97c83303dc020f8fc7d3e82
|
|
4
|
+
data.tar.gz: 8aee6cf2bfc9c2e16dee2440ebfb4292f6458eeacb355ebe4a40bebdb5d8a43b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a4161e4bbf0f5593ef3d0a0b2cc8b2a2b49c5456466e4e1252e8ffccb8ec497a10c455fb2389da59f5dd0b30e4c0a5baaec3bd400a1d91174f7158c2520e9d3
|
|
7
|
+
data.tar.gz: 953f19659c46174dd935642bb8f99a4d846cc7dc892f9c7bbc992ee5e1272851a7d1526edfbec71ce1ec6581778a273ec86f5cd94cbee0b73086e6f253c0ef40
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`, `
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
8
|
-
# alter and reconnect are picked up without a cache here. Inside Model.transaction operations,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 { |
|
|
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
|
|
95
|
-
|
|
83
|
+
def row(found)
|
|
84
|
+
found && Row.new(attributes: found.attributes, tuple: found.tuple)
|
|
96
85
|
end
|
|
97
86
|
|
|
98
87
|
def unique
|
|
@@ -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
|