tarantool-model 0.1.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +271 -0
- data/lib/tarantool/model/counters.rb +30 -0
- data/lib/tarantool/model/errors.rb +43 -0
- data/lib/tarantool/model/key.rb +13 -0
- data/lib/tarantool/model/persistence.rb +107 -0
- data/lib/tarantool/model/querying.rb +30 -0
- data/lib/tarantool/model/relation.rb +78 -0
- data/lib/tarantool/model/row.rb +10 -0
- data/lib/tarantool/model/settings.rb +52 -0
- data/lib/tarantool/model/state.rb +64 -0
- data/lib/tarantool/model/storage/box/layout.rb +44 -0
- data/lib/tarantool/model/storage/box.rb +108 -0
- data/lib/tarantool/model/storage/crud.rb +80 -0
- data/lib/tarantool/model/transactional.rb +17 -0
- data/lib/tarantool/model/transactions.rb +40 -0
- data/lib/tarantool/model/types/binary.rb +18 -0
- data/lib/tarantool/model/types/interval.rb +18 -0
- data/lib/tarantool/model/types/uuid.rb +21 -0
- data/lib/tarantool/model/version.rb +7 -0
- data/lib/tarantool/model.rb +42 -0
- metadata +106 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fb88c7317a6b38d448f89c6ca24ad43f2e7c4b3da9a936795fae74a966b4873e
|
|
4
|
+
data.tar.gz: c098a1157a2539596e6789d9717c883ed1d135f260d49b44d2018e1b42f53243
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 16b134ac4b0b0efe415455fcd09b4e314bc5ac91ed5e0408cac646298ce49a78d59ce86d74afe981edc9b0c4aacf2bb1ce395206912151e67c637555537d6303
|
|
7
|
+
data.tar.gz: f9bf98de1e3c83bae3ccf399965e877887556683b8575fc54aa8ac75af2adf53412ee0af175d24dc4e589b01bf564652240cf00c0eab2ebabcbe574f6e515f06
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `Tarantool::Model` mixin on ActiveModel: attributes, validations, callbacks, dirty tracking, JSON.
|
|
8
|
+
- `Types::UUID`, `Types::Interval`, `Types::Binary`.
|
|
9
|
+
- `Storage::Box` over `Tarantool::Client` and `Pool` with read/write options.
|
|
10
|
+
- Persistence: create, save, update, destroy, reload, increment!, decrement!.
|
|
11
|
+
- Index queries with keyset pagination: find, find_by, where, limit, after, find_each.
|
|
12
|
+
- `Model.transaction` over `Storage::Box`, `TransactionMismatchError` for writes through another storage.
|
|
13
|
+
- `Storage::Crud` over `Tarantool::Crud::Client`; raises `Tarantool::Model::Error` when tarantool-crud is not loaded.
|
|
14
|
+
- Depends on `tarantool-client ~> 0.1.0`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Starlexxx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# tarantool-model
|
|
2
|
+
|
|
3
|
+
ActiveModel-based models for Tarantool 2.11+ and 3.x spaces. Provides attributes, validations, callbacks,
|
|
4
|
+
dirty tracking, JSON serialization, and index-based queries with keyset pagination. This is not ActiveRecord:
|
|
5
|
+
there is no schema migration, no associations, and no query builder. Storage is `Storage::Box` over
|
|
6
|
+
`tarantool-client` or `Storage::Crud` over `tarantool/crud`.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Requirements: Ruby >= 3.2, Tarantool 2.11+ or 3.x.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'tarantool-model'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
require 'tarantool/model'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Declaring a model
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
pool = Tarantool::Client::Pool.new(['tcp://app:secret@db1:3301', 'tcp://app:secret@db2:3301'])
|
|
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
|
|
35
|
+
|
|
36
|
+
class User
|
|
37
|
+
include ApplicationModel
|
|
38
|
+
|
|
39
|
+
attribute :id, :integer
|
|
40
|
+
attribute :email, :string
|
|
41
|
+
attribute :age, :integer, default: 0
|
|
42
|
+
|
|
43
|
+
validates :email, presence: true
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`Storage::Box` passes its `read:`/`write:` options verbatim to the client's read and write requests. The `Pool`
|
|
48
|
+
routing `mode:` (symbols like `:prefer_ro`, `:rw`; requires a `Pool` as transport) is one example; `timeout:` works
|
|
49
|
+
too. `Storage::Crud` takes no `read:`/`write:`: crud defaults are set on `Tarantool::Crud::Client.new(..., read:,
|
|
50
|
+
write:)` and use crud's own options (strings: `mode: 'read'`).
|
|
51
|
+
|
|
52
|
+
Attribute names must match the space format field names exactly. `space_name` defaults to the plural model
|
|
53
|
+
name; set it explicitly with `User.space_name = :members`. The primary key defaults to `:id`; set a composite
|
|
54
|
+
key with `User.primary_key = %i[tenant_id id]`.
|
|
55
|
+
|
|
56
|
+
On the first operation the storage checks that every declared attribute exists in the space format and that
|
|
57
|
+
the primary key matches index 0. A mismatch raises `SchemaMismatchError`.
|
|
58
|
+
|
|
59
|
+
## Types
|
|
60
|
+
|
|
61
|
+
| Attribute type | Ruby class |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `:integer` | `Integer` |
|
|
64
|
+
| `:string` | `String` |
|
|
65
|
+
| `:decimal` | `BigDecimal` |
|
|
66
|
+
| `:datetime` | `Time` |
|
|
67
|
+
| `:boolean` | `true` / `false` |
|
|
68
|
+
| `Types::UUID.new` | `Tarantool::Client::UUID` |
|
|
69
|
+
| `Types::Interval.new` | `Tarantool::Client::Interval` |
|
|
70
|
+
| `Types::Binary.new` | `String` with `Encoding::BINARY` |
|
|
71
|
+
|
|
72
|
+
Custom types are passed as objects, not registered globally in `ActiveModel::Type`. A malformed UUID string
|
|
73
|
+
casts to `nil`. In-place mutation of a Hash or Array value is not tracked as a change — reassign to mark dirty.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
attribute :token, Tarantool::Model::Types::UUID.new
|
|
77
|
+
attribute :period, Tarantool::Model::Types::Interval.new
|
|
78
|
+
attribute :avatar, Tarantool::Model::Types::Binary.new
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Persistence
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
user = User.new(id: 1, email: 'a@b.c', age: 20)
|
|
85
|
+
user.save # => true / false (runs validations)
|
|
86
|
+
user.save! # raises RecordInvalid / RecordNotSaved
|
|
87
|
+
|
|
88
|
+
user = User.create(id: 2, email: 'b@c.d')
|
|
89
|
+
User.create!(id: 3, email: 'c@d.e')
|
|
90
|
+
|
|
91
|
+
user.update(age: 21) # assign + save
|
|
92
|
+
user.update!(age: 21)
|
|
93
|
+
user.reload # re-reads from storage, discards unsaved changes
|
|
94
|
+
user.destroy # returns self (frozen) or false when a before_destroy callback aborts
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Only changed attributes are sent on update. Attributes are refreshed from the row the storage returns after
|
|
98
|
+
every write, so server-side sequences and defaults appear immediately. The primary key cannot change on a
|
|
99
|
+
persisted record.
|
|
100
|
+
|
|
101
|
+
When the row is gone before an update or reload, `RecordNotFound` is raised. When a `before_*` callback
|
|
102
|
+
throws `:abort`, `save` returns `false` and `save!` raises `RecordNotSaved`.
|
|
103
|
+
|
|
104
|
+
Callbacks: `before_validation`, `after_validation`, `before_save`, `around_save`, `after_save`,
|
|
105
|
+
`before_create`, `around_create`, `after_create`, `before_update`, `around_update`, `after_update`,
|
|
106
|
+
`before_destroy`, `around_destroy`, `after_destroy`.
|
|
107
|
+
|
|
108
|
+
### Counters
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
user.increment!(:age) # adds 1 on the server
|
|
112
|
+
user.increment!(:age, 5)
|
|
113
|
+
user.decrement!(:age)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Counters skip validations and callbacks. Only the counter attribute and the raw tuple are refreshed from the
|
|
117
|
+
response; other unsaved changes on the record survive. Raises `RecordNotFound` if the row is gone.
|
|
118
|
+
|
|
119
|
+
## Querying
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
User.find(1) # by primary key, raises RecordNotFound
|
|
123
|
+
User.find([tenant_id, id]) # composite key as Array
|
|
124
|
+
User.find_by(index: :email, key: 'a@b.c') # first match or nil
|
|
125
|
+
|
|
126
|
+
User.all # Relation over the primary index
|
|
127
|
+
User.where(index: :age, key: 20, iterator: :ge) # one index condition
|
|
128
|
+
.limit(50)
|
|
129
|
+
.after(last_record) # keyset pagination, continues after a tuple
|
|
130
|
+
.to_a # Array of model instances
|
|
131
|
+
User.where(index: :age, key: 20).first # limit(1).to_a.first
|
|
132
|
+
|
|
133
|
+
User.find_each(batch_size: 200) { |user| ... } # walks in pages, returns Enumerator without block
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A `Relation` is `Enumerable` and re-runs the query on every enumeration. `limit` and `after` return new
|
|
137
|
+
immutable relations. Only one `where` per relation is allowed.
|
|
138
|
+
|
|
139
|
+
Index order is ascending by default. `:lt` and `:le` iterators give descending order, with ties broken by
|
|
140
|
+
primary key descending.
|
|
141
|
+
|
|
142
|
+
**A relation without `limit` selects every matching row in one request.** Use `find_each` for large result
|
|
143
|
+
sets, or set an explicit `limit`.
|
|
144
|
+
|
|
145
|
+
`count` without a block or argument delegates to the storage. `Storage::Box` raises `NotSupportedError`
|
|
146
|
+
because IPROTO has no count request and calling `index:count()` in Lua requires execute grants on universe
|
|
147
|
+
that an application user typically lacks. Pass a block or argument to enumerate instead:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
User.all.count # NotSupportedError on Storage::Box
|
|
151
|
+
User.all.count { |user| user.age > 18 } # enumerates, returns Integer
|
|
152
|
+
User.all.count(some_user) # enumerates, counts elements equal to some_user
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Transactions
|
|
156
|
+
|
|
157
|
+
`Storage::Box` supports interactive transactions via the Tarantool MVCC engine.
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
User.transaction(isolation: :read_committed, timeout: 5) do
|
|
161
|
+
user = User.create!(id: 1, email: 'a@b.c')
|
|
162
|
+
user.update!(age: 21)
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
All models in one transaction must share the same storage instance. A write through a different storage
|
|
167
|
+
instance raises `TransactionMismatchError` before sending anything to Tarantool; reads through another
|
|
168
|
+
storage are allowed. If the block raises, the transaction rolls back and the error propagates. In-memory
|
|
169
|
+
record state (attribute values, `persisted?`) is not reverted on rollback. A nested `transaction` call
|
|
170
|
+
raises `Tarantool::TransactionError`. `Storage::Crud` does not support transactions and raises
|
|
171
|
+
`NotSupportedError`.
|
|
172
|
+
|
|
173
|
+
## Storage::Crud
|
|
174
|
+
|
|
175
|
+
`Storage::Crud` maps the storage contract onto a `Tarantool::Crud::Client`. Require the `tarantool-crud`
|
|
176
|
+
gem yourself before using it.
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
require 'tarantool/crud'
|
|
180
|
+
|
|
181
|
+
crud = Tarantool::Crud::Client.new(Tarantool::Client.new('tcp://app:secret@router:3301'))
|
|
182
|
+
|
|
183
|
+
class Customer
|
|
184
|
+
include Tarantool::Model
|
|
185
|
+
|
|
186
|
+
self.space_name = :users
|
|
187
|
+
attribute :id, :integer
|
|
188
|
+
attribute :email, :string
|
|
189
|
+
attribute :age, :integer, default: 0
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
Customer.storage = Tarantool::Model::Storage::Crud.new(crud, primary_index: :primary)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Do not declare `bucket_id` as a model attribute; the sharding key is managed by crud and does not need to
|
|
196
|
+
appear in your model. Primary key values must be supplied by the caller; crud cannot route a nil sharding key.
|
|
197
|
+
|
|
198
|
+
Supported iterators: `eq`, `ge`, `gt`, `le`, `lt`, `all`. Any other iterator raises `ArgumentError`. `count`
|
|
199
|
+
on a relation works. Schema is not verified on the first operation.
|
|
200
|
+
|
|
201
|
+
**A wrong `primary_index:` name is treated by crud as a field name and silently returns nothing.** This affects
|
|
202
|
+
queries that walk the primary index: `all`, `where`/`find_by` without an index name, and `find_each`. `find` reads
|
|
203
|
+
by key with crud `get` and does not use it. Make sure the index name matches the space definition.
|
|
204
|
+
|
|
205
|
+
Keyset pagination with `after` requires a tuple, which `select` returns but single-row operations (`find`,
|
|
206
|
+
`find_by`) do not. Calling `after` on a record loaded with `find` raises `ArgumentError`.
|
|
207
|
+
|
|
208
|
+
## Rails
|
|
209
|
+
|
|
210
|
+
`to_param` joins a composite key with `-` (e.g. `"7-1"`) for route helpers. `as_json` works through
|
|
211
|
+
`ActiveModel::Serializers::JSON`. `form_with` works with any persisted model.
|
|
212
|
+
|
|
213
|
+
There is no Railtie. Configure storage in an initializer:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
# config/initializers/tarantool.rb
|
|
217
|
+
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 })
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Errors
|
|
222
|
+
|
|
223
|
+
`Tarantool::Model::Error` descends from `Tarantool::Error`, so one `rescue Tarantool::Error` also catches
|
|
224
|
+
client and crud errors.
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
Tarantool::Model::Error
|
|
228
|
+
├── RecordNotFound # #model, #key
|
|
229
|
+
├── RecordInvalid # #record
|
|
230
|
+
├── RecordNotSaved # #record; a before callback aborted
|
|
231
|
+
├── RecordNotUnique # duplicate primary or unique secondary key
|
|
232
|
+
├── SchemaMismatchError # attribute or primary key does not match the space format
|
|
233
|
+
├── NotSupportedError # operation not available for this storage (e.g. count on Storage::Box)
|
|
234
|
+
└── TransactionMismatchError # write through a storage that has no open transaction in the current fiber
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
```sh
|
|
240
|
+
podman compose -f tarantool-model/docker-compose.yml up -d --wait box-2.11 box-3
|
|
241
|
+
# or: docker compose -f tarantool-model/docker-compose.yml up -d --wait box-2.11 box-3
|
|
242
|
+
|
|
243
|
+
cd tarantool-model
|
|
244
|
+
bundle exec rspec # unit specs
|
|
245
|
+
INTEGRATION=1 TARANTOOL_MODEL_URI=tcp://app:secret@localhost:3321 bundle exec rspec # 2.11
|
|
246
|
+
INTEGRATION=1 TARANTOOL_MODEL_URI=tcp://app:secret@localhost:3322 bundle exec rspec # 3.x
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The `app` user needs `read,write` on universe. Specs truncate spaces through `client.eval`, which requires
|
|
250
|
+
`execute` on universe as well; grant `read,write,execute` if truncation is denied.
|
|
251
|
+
|
|
252
|
+
For `Storage::Crud` specs, start the crud services (requires the `tarantool-crud` docker image):
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
podman compose -f tarantool-model/docker-compose.yml up -d --wait --build crud-2.11 crud-3
|
|
256
|
+
|
|
257
|
+
TARANTOOL_MODEL_CRUD_URI=tcp://app:secret@localhost:3331 bundle exec rspec --tag crud # 2.11
|
|
258
|
+
TARANTOOL_MODEL_CRUD_URI=tcp://app:secret@localhost:3332 bundle exec rspec --tag crud # 3.x
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Public API
|
|
262
|
+
|
|
263
|
+
`Tarantool::Model`, `Storage::Box`, `Storage::Crud`, `Types::UUID`, `Types::Interval`, `Types::Binary`,
|
|
264
|
+
`Relation`, and the error classes above.
|
|
265
|
+
|
|
266
|
+
Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
|
|
267
|
+
and may change in any release.
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Atomic '+'/'-' on the server, without validations or callbacks. Only the counter is taken from the
|
|
6
|
+
# response, so other unsaved changes of the record survive.
|
|
7
|
+
module Counters
|
|
8
|
+
def increment!(attribute, by = 1)
|
|
9
|
+
change_counter('+', attribute, by)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def decrement!(attribute, by = 1)
|
|
13
|
+
change_counter('-', attribute, by)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def change_counter(operator, attribute, by)
|
|
19
|
+
row = writing_storage.update(space_name, key_in_database, [[operator, attribute.to_s, by]])
|
|
20
|
+
raise RecordNotFound.new(self.class, key_in_database) unless row
|
|
21
|
+
|
|
22
|
+
@tuple = row.tuple
|
|
23
|
+
public_send("#{attribute}=", row.attributes.fetch(attribute.to_sym))
|
|
24
|
+
clear_attribute_changes([attribute])
|
|
25
|
+
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
class Error < Tarantool::Error; end
|
|
6
|
+
|
|
7
|
+
# No row in the storage for this model's primary key.
|
|
8
|
+
class RecordNotFound < Error
|
|
9
|
+
attr_reader :model, :key
|
|
10
|
+
|
|
11
|
+
def initialize(model, key)
|
|
12
|
+
@model = model
|
|
13
|
+
@key = key
|
|
14
|
+
super("#{model.name} with key #{key.inspect} not found")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# save! or create! on a record that fails validation.
|
|
19
|
+
class RecordInvalid < Error
|
|
20
|
+
attr_reader :record
|
|
21
|
+
|
|
22
|
+
def initialize(record)
|
|
23
|
+
@record = record
|
|
24
|
+
super(record.errors.full_messages.join(', '))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A before callback threw :abort.
|
|
29
|
+
class RecordNotSaved < Error
|
|
30
|
+
attr_reader :record
|
|
31
|
+
|
|
32
|
+
def initialize(record)
|
|
33
|
+
@record = record
|
|
34
|
+
super("#{record.class.name} was not saved: a before callback aborted")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class RecordNotUnique < Error; end
|
|
39
|
+
class SchemaMismatchError < Error; end
|
|
40
|
+
class TransactionMismatchError < Error; end
|
|
41
|
+
class NotSupportedError < Error; end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Index keys are Arrays; Kernel#Array would split a Time or a Hash.
|
|
6
|
+
# @api private
|
|
7
|
+
module Key
|
|
8
|
+
def self.wrap(value)
|
|
9
|
+
value.is_a?(Array) ? value : [value]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# create, save, update, destroy and reload with validations and callbacks. Every write refreshes the
|
|
6
|
+
# attributes from the row the storage returns, so server-side defaults and sequences show up.
|
|
7
|
+
module Persistence
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
extend ActiveModel::Callbacks
|
|
12
|
+
|
|
13
|
+
define_model_callbacks :save, :create, :update, :destroy
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def create(attributes = {})
|
|
18
|
+
new(attributes).tap(&:save)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create!(attributes = {})
|
|
22
|
+
new(attributes).tap(&:save!)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def save
|
|
27
|
+
valid? && persist
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def save!
|
|
31
|
+
raise RecordInvalid, self unless valid?
|
|
32
|
+
|
|
33
|
+
persist || raise(RecordNotSaved, self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update(attributes)
|
|
37
|
+
assign_attributes(attributes)
|
|
38
|
+
|
|
39
|
+
save
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update!(attributes)
|
|
43
|
+
assign_attributes(attributes)
|
|
44
|
+
|
|
45
|
+
save!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A row that is already gone still counts as destroyed.
|
|
49
|
+
def destroy
|
|
50
|
+
return false unless run_callbacks(:destroy) { delete_row }
|
|
51
|
+
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reload
|
|
56
|
+
row = space_storage.get(space_name, key_in_database)
|
|
57
|
+
raise RecordNotFound.new(self.class, key_in_database) unless row
|
|
58
|
+
|
|
59
|
+
assign_row(row)
|
|
60
|
+
clear_changes_information
|
|
61
|
+
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def persist
|
|
68
|
+
run_callbacks(:save) { new_record? ? create_row : update_row }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def create_row
|
|
72
|
+
run_callbacks(:create) do
|
|
73
|
+
written(writing_storage.insert(space_name, attributes.transform_keys(&:to_sym)))
|
|
74
|
+
|
|
75
|
+
@persisted = true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def update_row
|
|
80
|
+
raise ArgumentError, "the primary key of a persisted #{self.class.name} cannot change" if primary_key_changed?
|
|
81
|
+
|
|
82
|
+
run_callbacks(:update) do
|
|
83
|
+
written(updated_row) if changed?
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def updated_row
|
|
90
|
+
operations = changes.map { |name, (_was, value)| ['=', name, value] }
|
|
91
|
+
|
|
92
|
+
writing_storage.update(space_name, key_in_database, operations) ||
|
|
93
|
+
raise(RecordNotFound.new(self.class, key_in_database))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete_row
|
|
97
|
+
writing_storage.delete(space_name, key_in_database) if persisted?
|
|
98
|
+
|
|
99
|
+
@destroyed = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def primary_key_changed?
|
|
103
|
+
self.class.primary_key_names.any? { |name| attribute_changed?(name.to_s) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Class-level reads; everything but find goes through a Relation.
|
|
6
|
+
module Querying
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
def all = Relation.new(self)
|
|
11
|
+
|
|
12
|
+
def where(...) = all.where(...)
|
|
13
|
+
|
|
14
|
+
def find_each(...) = all.find_each(...)
|
|
15
|
+
|
|
16
|
+
def count = all.count
|
|
17
|
+
|
|
18
|
+
def find_by(index:, key:) = where(index:, key:).first
|
|
19
|
+
|
|
20
|
+
def find(key)
|
|
21
|
+
key = Key.wrap(key)
|
|
22
|
+
row = verified_storage.get(space_name, key)
|
|
23
|
+
raise RecordNotFound.new(self, key) unless row
|
|
24
|
+
|
|
25
|
+
instantiate(row)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# An immutable query over one index condition. The query runs on every enumeration; results are not cached.
|
|
6
|
+
# Order is the index order (descending for :le/:lt); pagination is keyset only, with #after.
|
|
7
|
+
class Relation
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
DEFAULTS = { index: nil, key: [], iterator: :all, limit: nil, after: nil, filtered: false }.freeze
|
|
11
|
+
QUERY = %i[index key iterator].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(model, **options)
|
|
14
|
+
@model = model
|
|
15
|
+
@options = DEFAULTS.merge(options).freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def where(index: nil, key: [], iterator: :eq)
|
|
20
|
+
raise ArgumentError, 'a relation takes one index condition, where was already called' if @options[:filtered]
|
|
21
|
+
|
|
22
|
+
spawn(index:, key: Key.wrap(key), iterator:, filtered: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def limit(count)
|
|
26
|
+
spawn(limit: count)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def after(record)
|
|
30
|
+
raise ArgumentError, "#{record.class.name} has no tuple to continue after" unless record.tuple
|
|
31
|
+
|
|
32
|
+
spawn(after: record.tuple)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def each
|
|
36
|
+
return enum_for(:each) unless block_given?
|
|
37
|
+
|
|
38
|
+
rows.each { |row| yield @model.instantiate(row) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def first(count = nil)
|
|
42
|
+
count ? limit(count).to_a : limit(1).to_a.first
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Every match of the index condition: limit and after are ignored.
|
|
46
|
+
# With a block or argument, delegates to Enumerable#count (enumerates).
|
|
47
|
+
def count(*args, &block)
|
|
48
|
+
return @model.verified_storage.count(@model.space_name, **@options.slice(*QUERY)) if args.empty? && !block
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def find_each(batch_size: 1000, &block)
|
|
54
|
+
return enum_for(:find_each, batch_size:) unless block
|
|
55
|
+
|
|
56
|
+
relation = limit(batch_size)
|
|
57
|
+
|
|
58
|
+
loop do
|
|
59
|
+
batch = relation.to_a
|
|
60
|
+
batch.each(&block)
|
|
61
|
+
break if batch.size < batch_size
|
|
62
|
+
|
|
63
|
+
relation = relation.after(batch.last)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def rows
|
|
70
|
+
@model.verified_storage.select(@model.space_name, **@options.slice(*QUERY, :limit, :after))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def spawn(**changes)
|
|
74
|
+
self.class.new(@model, **@options, **changes)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# What a storage returns for one row: attributes by Symbol name, and the raw tuple as the server returned it,
|
|
6
|
+
# or nil when the storage has none.
|
|
7
|
+
# @api private
|
|
8
|
+
Row = Data.define(:attributes, :tuple)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Storage, space name and primary key of a model class, inherited by subclasses.
|
|
6
|
+
module Settings
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :storage, instance_accessor: false
|
|
11
|
+
class_attribute :primary_key, instance_accessor: false, default: :id
|
|
12
|
+
# @api private
|
|
13
|
+
class_attribute :_space_name, instance_accessor: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def space_name
|
|
18
|
+
_space_name || model_name.plural
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def space_name=(name)
|
|
22
|
+
self._space_name = name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def primary_key_names
|
|
26
|
+
Array(primary_key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The schema check runs once per class and storage, before the first operation.
|
|
30
|
+
# @api private
|
|
31
|
+
def verified_storage
|
|
32
|
+
current = storage
|
|
33
|
+
raise Error, "#{name}.storage is not set" unless current
|
|
34
|
+
return current if @verified_storage.equal?(current)
|
|
35
|
+
|
|
36
|
+
current.verify(space_name, attributes: attribute_names.map(&:to_sym), primary_key: primary_key_names)
|
|
37
|
+
|
|
38
|
+
@verified_storage = current
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
def writing_storage
|
|
43
|
+
verified_storage.tap { |s| Transactions.check_write!(s) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_key
|
|
48
|
+
self.class.primary_key_names.map { |name| public_send(name) } if persisted?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Persistence state and the raw tuple a record was loaded from or last written with; Relation#after
|
|
6
|
+
# continues past that tuple.
|
|
7
|
+
module State
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
# @api private
|
|
12
|
+
def instantiate(row)
|
|
13
|
+
new.tap { |record| record.send(:init_with_row, row) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :tuple
|
|
18
|
+
|
|
19
|
+
def new_record? = !@persisted
|
|
20
|
+
|
|
21
|
+
def persisted? = @persisted == true && !destroyed?
|
|
22
|
+
|
|
23
|
+
def destroyed? = @destroyed == true
|
|
24
|
+
|
|
25
|
+
# Assigning to a destroyed record must fail; freezing self alone leaves the attribute set writable.
|
|
26
|
+
def freeze
|
|
27
|
+
@attributes = @attributes.clone.freeze
|
|
28
|
+
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def init_with_row(row)
|
|
35
|
+
assign_row(row)
|
|
36
|
+
clear_changes_information
|
|
37
|
+
|
|
38
|
+
@persisted = true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def assign_row(row)
|
|
42
|
+
@tuple = row.tuple
|
|
43
|
+
|
|
44
|
+
assign_attributes(row.attributes.slice(*self.class.attribute_names.map(&:to_sym)))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def written(row)
|
|
48
|
+
assign_row(row)
|
|
49
|
+
|
|
50
|
+
changes_applied
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def key_in_database
|
|
54
|
+
self.class.primary_key_names.map { |name| attribute_was(name.to_s) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def space_storage = self.class.verified_storage
|
|
58
|
+
|
|
59
|
+
def writing_storage = self.class.writing_storage
|
|
60
|
+
|
|
61
|
+
def space_name = self.class.space_name
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Storage
|
|
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.
|
|
9
|
+
# @api private
|
|
10
|
+
class Layout
|
|
11
|
+
def initialize(space_info)
|
|
12
|
+
@space_info = space_info
|
|
13
|
+
@names = space_info.fields.map { |field| field.name.to_sym }
|
|
14
|
+
end
|
|
15
|
+
|
|
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
|
+
def verify!(attributes:, primary_key:)
|
|
25
|
+
missing = attributes - @names
|
|
26
|
+
raise SchemaMismatchError, "space #{@space_info.name} has no field #{missing.join(', ')}" if missing.any?
|
|
27
|
+
raise SchemaMismatchError, "space #{@space_info.name} has no primary index" unless @space_info.primary_index
|
|
28
|
+
|
|
29
|
+
return if primary_key == primary_parts
|
|
30
|
+
|
|
31
|
+
raise SchemaMismatchError,
|
|
32
|
+
"primary key #{primary_key.inspect} does not match the parts of index 0: #{primary_parts.inspect}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def primary_parts
|
|
38
|
+
@space_info.primary_index&.parts&.map { |part| part.name&.to_sym }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Storage
|
|
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.
|
|
10
|
+
class Box
|
|
11
|
+
ER_TUPLE_FOUND = 3
|
|
12
|
+
private_constant :ER_TUPLE_FOUND
|
|
13
|
+
|
|
14
|
+
def initialize(caller, read: {}, write: {})
|
|
15
|
+
if [read, write].any? { |options| options.key?(:mode) } && !caller.is_a?(Tarantool::Client::Pool)
|
|
16
|
+
raise ArgumentError, 'mode: needs a Tarantool::Client::Pool'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@caller = caller
|
|
20
|
+
@read_options = read
|
|
21
|
+
@write_options = write
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def verify(space, attributes:, primary_key:)
|
|
25
|
+
layout(space, read).verify!(attributes:, primary_key:)
|
|
26
|
+
end
|
|
27
|
+
|
|
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)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
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)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
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)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete(space, key)
|
|
50
|
+
layout = layout(space, write)
|
|
51
|
+
tuple = client.delete(space, key, **write)
|
|
52
|
+
|
|
53
|
+
tuple && layout.to_row(tuple)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def select(space, index:, key:, iterator:, limit:, after:)
|
|
57
|
+
layout = layout(space, read)
|
|
58
|
+
options = { index: index || 0, iterator:, after:, **read }
|
|
59
|
+
options[:limit] = limit if limit
|
|
60
|
+
|
|
61
|
+
client.select(space, key, **options).map { |tuple| layout.to_row(tuple) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# IPROTO has no count request, and calling box.space.X.index.Y:count needs execute grants
|
|
65
|
+
# (lua_call on 3.x, universe on 2.11) that an application user usually lacks.
|
|
66
|
+
def count(_space, **)
|
|
67
|
+
raise NotSupportedError, 'Storage::Box does not count'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def transaction(**options, &block)
|
|
71
|
+
@caller.transaction(**options) do |stream|
|
|
72
|
+
Transactions.open(self, stream, &block)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def client
|
|
79
|
+
Transactions.handle(self) || @caller
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read
|
|
83
|
+
scoped(@read_options)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write
|
|
87
|
+
scoped(@write_options)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def scoped(options)
|
|
91
|
+
Transactions.handle(self) ? options.except(:mode) : options
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def layout(space, options)
|
|
95
|
+
Layout.new(client.space_info(space, **options.slice(:mode, :timeout)))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def unique
|
|
99
|
+
yield
|
|
100
|
+
rescue Tarantool::ServerError => e
|
|
101
|
+
raise RecordNotUnique, e.message if e.code == ER_TUPLE_FOUND
|
|
102
|
+
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Storage
|
|
6
|
+
# Storage over a Tarantool::Crud::Client; read/write defaults live in that client. crud has no schema
|
|
7
|
+
# lookup, so the format is not checked and `index: nil` means the index named `primary_index:`. crud
|
|
8
|
+
# reads an unknown index name as a field name and returns nothing, so that name must be right.
|
|
9
|
+
# Single-row operations return no tuple; only select rows can be continued after.
|
|
10
|
+
class Crud
|
|
11
|
+
OPERATORS = { eq: '==', ge: '>=', gt: '>', le: '<=', lt: '<' }.freeze
|
|
12
|
+
DUPLICATE_KEY = /Duplicate key exists/
|
|
13
|
+
|
|
14
|
+
# tarantool-model doesn't depend on tarantool-crud, yet rescues its errors.
|
|
15
|
+
def initialize(crud, primary_index: :primary)
|
|
16
|
+
raise Error, "tarantool-crud is not loaded; require 'tarantool/crud'" unless defined?(Tarantool::Crud)
|
|
17
|
+
|
|
18
|
+
@crud = crud
|
|
19
|
+
@primary_index = primary_index
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def verify(*, **) = nil
|
|
23
|
+
|
|
24
|
+
def get(space, key)
|
|
25
|
+
row(@crud.space(space).get(key))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def insert(space, attributes)
|
|
29
|
+
row(unique { @crud.space(space).insert(attributes) })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update(space, key, operations)
|
|
33
|
+
row(unique { @crud.space(space).update(key, operations) })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(space, key)
|
|
37
|
+
row(@crud.space(space).delete(key))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def select(space, index:, key:, iterator:, limit:, after:)
|
|
41
|
+
options = { first: limit, after: }.compact
|
|
42
|
+
result = @crud.space(space).select(conditions(index, key, iterator), **options)
|
|
43
|
+
|
|
44
|
+
result.zip(result.tuples).map { |attributes, tuple| Row.new(attributes:, tuple:) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def count(space, index:, key:, iterator:)
|
|
48
|
+
@crud.space(space).count(conditions(index, key, iterator))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def transaction(**)
|
|
52
|
+
raise NotSupportedError, 'crud has no interactive transactions'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def row(attributes)
|
|
58
|
+
attributes && Row.new(attributes:, tuple: nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def conditions(index, key, iterator)
|
|
62
|
+
return if iterator == :all
|
|
63
|
+
|
|
64
|
+
operator = OPERATORS.fetch(iterator) { raise ArgumentError, "crud has no condition for #{iterator.inspect}" }
|
|
65
|
+
return if key.empty?
|
|
66
|
+
|
|
67
|
+
[[operator, (index || @primary_index).to_s, key]]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unique
|
|
71
|
+
yield
|
|
72
|
+
rescue Tarantool::Crud::InsertError, Tarantool::Crud::UpdateError => e
|
|
73
|
+
raise RecordNotUnique, e.message if e.message.match?(DUPLICATE_KEY)
|
|
74
|
+
|
|
75
|
+
raise
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Model.transaction delegates to the storage; every model sharing that storage instance writes through the
|
|
6
|
+
# open transaction. In-memory record state is not reverted on rollback.
|
|
7
|
+
module Transactional
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
def transaction(**, &)
|
|
12
|
+
verified_storage.transaction(**, &)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
# Open transactions of the current fiber, keyed by storage instance (Thread#[] is fiber-local). A model
|
|
6
|
+
# whose storage has no open transaction would write outside the one that is open, and commit on its own.
|
|
7
|
+
# @api private
|
|
8
|
+
module Transactions
|
|
9
|
+
KEY = :tarantool_model_transactions
|
|
10
|
+
MISMATCH = 'a write through another storage inside a transaction would commit outside it; ' \
|
|
11
|
+
'models in one transaction must share one storage instance'
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def open(storage, handle)
|
|
16
|
+
registry = current
|
|
17
|
+
registry[storage] = handle
|
|
18
|
+
|
|
19
|
+
yield
|
|
20
|
+
ensure
|
|
21
|
+
registry&.delete(storage)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def handle(storage)
|
|
25
|
+
current[storage]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_write!(storage)
|
|
29
|
+
registry = current
|
|
30
|
+
return if registry.empty? || registry.key?(storage)
|
|
31
|
+
|
|
32
|
+
raise TransactionMismatchError, MISMATCH
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def current
|
|
36
|
+
Thread.current[KEY] ||= {}.compare_by_identity
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Types
|
|
6
|
+
# varbinary: msgpack sends an ASCII-8BIT String as bin and a UTF-8 one as str.
|
|
7
|
+
class Binary < ActiveModel::Type::Value
|
|
8
|
+
def type = :binary
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def cast_value(value)
|
|
13
|
+
value.is_a?(String) ? value.b : value
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Types
|
|
6
|
+
# MP_EXT interval; a Hash of units (e.g. from params) builds one.
|
|
7
|
+
class Interval < ActiveModel::Type::Value
|
|
8
|
+
def type = :interval
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def cast_value(value)
|
|
13
|
+
value.is_a?(Hash) ? Tarantool::Client::Interval.new(**value.transform_keys(&:to_sym)) : value
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Model
|
|
5
|
+
module Types
|
|
6
|
+
# MP_EXT uuid. A malformed String casts to nil, as Rails' uuid types do.
|
|
7
|
+
class UUID < ActiveModel::Type::Value
|
|
8
|
+
def type = :uuid
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def cast_value(value)
|
|
13
|
+
return value if value.is_a?(Tarantool::Client::UUID)
|
|
14
|
+
return unless value.is_a?(String) && value.match?(Tarantool::Client::UUID::FORMAT)
|
|
15
|
+
|
|
16
|
+
Tarantool::Client::UUID.new(value:)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
require 'tarantool/client'
|
|
5
|
+
|
|
6
|
+
require_relative 'model/version'
|
|
7
|
+
require_relative 'model/errors'
|
|
8
|
+
require_relative 'model/row'
|
|
9
|
+
require_relative 'model/key'
|
|
10
|
+
require_relative 'model/transactions'
|
|
11
|
+
require_relative 'model/transactional'
|
|
12
|
+
require_relative 'model/types/uuid'
|
|
13
|
+
require_relative 'model/types/interval'
|
|
14
|
+
require_relative 'model/types/binary'
|
|
15
|
+
require_relative 'model/settings'
|
|
16
|
+
require_relative 'model/state'
|
|
17
|
+
require_relative 'model/persistence'
|
|
18
|
+
require_relative 'model/counters'
|
|
19
|
+
require_relative 'model/relation'
|
|
20
|
+
require_relative 'model/querying'
|
|
21
|
+
require_relative 'model/storage/box/layout'
|
|
22
|
+
require_relative 'model/storage/box'
|
|
23
|
+
require_relative 'model/storage/crud'
|
|
24
|
+
|
|
25
|
+
module Tarantool
|
|
26
|
+
# ActiveModel-based model over a Tarantool space. Attribute names are the space format field names.
|
|
27
|
+
module Model
|
|
28
|
+
extend ActiveSupport::Concern
|
|
29
|
+
|
|
30
|
+
include ActiveModel::API
|
|
31
|
+
include ActiveModel::Attributes
|
|
32
|
+
include ActiveModel::Dirty
|
|
33
|
+
include ActiveModel::Validations::Callbacks
|
|
34
|
+
include ActiveModel::Serializers::JSON
|
|
35
|
+
include Settings
|
|
36
|
+
include State
|
|
37
|
+
include Persistence
|
|
38
|
+
include Counters
|
|
39
|
+
include Transactional
|
|
40
|
+
include Querying
|
|
41
|
+
end
|
|
42
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tarantool-model
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Starlexxx
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activemodel
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.1'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.1'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: tarantool-client
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
40
|
+
type: :runtime
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.1.0
|
|
47
|
+
description: Attributes, validations, callbacks, dirty tracking and index-based queries
|
|
48
|
+
with keyset pagination over Tarantool 2.11+ and 3.x spaces, through tarantool-client
|
|
49
|
+
or tarantool-crud.
|
|
50
|
+
email:
|
|
51
|
+
- apavlovskiy088@gmail.com
|
|
52
|
+
executables: []
|
|
53
|
+
extensions: []
|
|
54
|
+
extra_rdoc_files: []
|
|
55
|
+
files:
|
|
56
|
+
- CHANGELOG.md
|
|
57
|
+
- LICENSE
|
|
58
|
+
- README.md
|
|
59
|
+
- lib/tarantool/model.rb
|
|
60
|
+
- lib/tarantool/model/counters.rb
|
|
61
|
+
- lib/tarantool/model/errors.rb
|
|
62
|
+
- lib/tarantool/model/key.rb
|
|
63
|
+
- lib/tarantool/model/persistence.rb
|
|
64
|
+
- lib/tarantool/model/querying.rb
|
|
65
|
+
- lib/tarantool/model/relation.rb
|
|
66
|
+
- lib/tarantool/model/row.rb
|
|
67
|
+
- lib/tarantool/model/settings.rb
|
|
68
|
+
- lib/tarantool/model/state.rb
|
|
69
|
+
- lib/tarantool/model/storage/box.rb
|
|
70
|
+
- lib/tarantool/model/storage/box/layout.rb
|
|
71
|
+
- lib/tarantool/model/storage/crud.rb
|
|
72
|
+
- lib/tarantool/model/transactional.rb
|
|
73
|
+
- lib/tarantool/model/transactions.rb
|
|
74
|
+
- lib/tarantool/model/types/binary.rb
|
|
75
|
+
- lib/tarantool/model/types/interval.rb
|
|
76
|
+
- lib/tarantool/model/types/uuid.rb
|
|
77
|
+
- lib/tarantool/model/version.rb
|
|
78
|
+
homepage: https://github.com/Starlexxx/tarantool-ruby
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata:
|
|
82
|
+
source_code_uri: https://github.com/Starlexxx/tarantool-ruby/tree/master/tarantool-model
|
|
83
|
+
changelog_uri: https://github.com/Starlexxx/tarantool-ruby/blob/master/tarantool-model/CHANGELOG.md
|
|
84
|
+
bug_tracker_uri: https://github.com/Starlexxx/tarantool-ruby/issues
|
|
85
|
+
homepage_uri: https://github.com/Starlexxx/tarantool-ruby
|
|
86
|
+
rubygems_mfa_required: 'true'
|
|
87
|
+
post_install_message:
|
|
88
|
+
rdoc_options: []
|
|
89
|
+
require_paths:
|
|
90
|
+
- lib
|
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.2'
|
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0'
|
|
101
|
+
requirements: []
|
|
102
|
+
rubygems_version: 3.5.22
|
|
103
|
+
signing_key:
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: ActiveModel-based models for Tarantool spaces
|
|
106
|
+
test_files: []
|