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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Model
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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: []