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 +4 -4
- data/CHANGELOG.md +8 -1
- data/README.md +80 -2
- data/lib/tarantool/model/storage/box/layout.rb +1 -10
- data/lib/tarantool/model/storage/box.rb +12 -25
- data/lib/tarantool/model/testing/memory_storage/box_mode.rb +47 -0
- data/lib/tarantool/model/testing/memory_storage/crud_mode.rb +79 -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 +97 -0
- data/lib/tarantool/model/testing.rb +17 -0
- data/lib/tarantool/model/version.rb +1 -1
- metadata +17 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b70f00434a73dd90e2c7fcd3b6fa9e73e3e9f2a4809da099d22301d6e64f024
|
|
4
|
+
data.tar.gz: cdd55a36929dd8d4a3cb8c10ff4e39e90c1aa304b2ccea431db3354caf80830b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f63d44c5c2cf9d9ac311efcfe0ee7a46d7819ce4c5681c9200eaaa0116aad14b810b36acdc39480c28cc2fbc9679cedace62acb02cb41da3ee55965d5928ea2
|
|
7
|
+
data.tar.gz: 912d7c7254e2c5e9aa4d8e045da95b04c18570eff8b0dd3ee4b8b088ce9837917e11776bfb350e86c648467f7bfba1d7af8627f0bc7dd63701e2ad8d573f1c99
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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`, `
|
|
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
|
-
#
|
|
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
|
|
@@ -91,8 +78,8 @@ module Tarantool
|
|
|
91
78
|
Transactions.handle(self) ? options.except(:mode) : options
|
|
92
79
|
end
|
|
93
80
|
|
|
94
|
-
def
|
|
95
|
-
|
|
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'
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|