tarantool-schema 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 +12 -0
- data/LICENSE +21 -0
- data/README.md +228 -0
- data/lib/tarantool/schema/comparison.rb +71 -0
- data/lib/tarantool/schema/creation.rb +36 -0
- data/lib/tarantool/schema/definition.rb +74 -0
- data/lib/tarantool/schema/errors.rb +29 -0
- data/lib/tarantool/schema/field_spec.rb +22 -0
- data/lib/tarantool/schema/format_comparison.rb +40 -0
- data/lib/tarantool/schema/index_comparison.rb +40 -0
- data/lib/tarantool/schema/index_spec.rb +55 -0
- data/lib/tarantool/schema/inspector.rb +33 -0
- data/lib/tarantool/schema/key_rules.rb +45 -0
- data/lib/tarantool/schema/live_space.rb +43 -0
- data/lib/tarantool/schema/options.rb +25 -0
- data/lib/tarantool/schema/part_spec.rb +36 -0
- data/lib/tarantool/schema/plan.rb +45 -0
- data/lib/tarantool/schema/space_rules.rb +75 -0
- data/lib/tarantool/schema/space_spec.rb +77 -0
- data/lib/tarantool/schema/types.rb +38 -0
- data/lib/tarantool/schema/version.rb +7 -0
- data/lib/tarantool/schema.rb +28 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 431a9d499bae2bc10348a4780b10ea5793e4debd8b021357f5f0a9edca753e00
|
|
4
|
+
data.tar.gz: 899bfb866f10c504ba4198899d2a42f850ae0b4a2fdb4c87a920f7c029097591
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d07771270e5545f01d696e9600de60b09c861f87e134feaad9e566ad42ee679bed2a512a302b28387bd44a219ef32d518c0a87d245af30088e2da1cc5fb2360a
|
|
7
|
+
data.tar.gz: df474e074921d8678a7c55f976de5e661dd9eba828591a4247cc6a30c2ef95c1d75b245df42fa567fa4e846cd29085487194c6cf8b7af6d1f041213f3b2499de
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `Tarantool::Schema::Definition`: `ensure_space` and `ensure_index` with `plan` and `apply` over an admin
|
|
8
|
+
`Tarantool::Client`. Creates missing spaces, indexes and primary key sequences; raises `MismatchError` with a
|
|
9
|
+
diff when the live schema differs, before creating anything. Never alters or drops.
|
|
10
|
+
- `Definition#to_h`: the normalized declaration, with defaults filled in and part types inherited; tarantool-model's
|
|
11
|
+
`Testing::MemoryStorage.from_definition` accepts the definition.
|
|
12
|
+
- Depends on `tarantool-client ~> 0.2.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,228 @@
|
|
|
1
|
+
# tarantool-schema
|
|
2
|
+
|
|
3
|
+
Idempotent schema declaration for Tarantool 2.11+ and 3.x, meant for deploys. You declare spaces, indexes and
|
|
4
|
+
primary key sequences in Ruby; `apply` creates whatever is missing and checks everything that already exists.
|
|
5
|
+
If the live schema differs from the declaration, it raises an error with a diff and creates nothing. It never
|
|
6
|
+
alters or drops anything: changing a field type or rebuilding an index is a migration and is out of scope.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Requirements: Ruby >= 3.2, Tarantool 2.11+ or 3.x.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'tarantool-schema'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
require 'tarantool/schema'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Declaring a schema
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
module MyApp
|
|
24
|
+
SCHEMA = Tarantool::Schema::Definition.new do
|
|
25
|
+
ensure_space :codes, engine: :memtx,
|
|
26
|
+
format: [
|
|
27
|
+
{ name: :id, type: :unsigned },
|
|
28
|
+
{ name: :code, type: :string },
|
|
29
|
+
{ name: :expires_at, type: :unsigned, is_nullable: true }
|
|
30
|
+
],
|
|
31
|
+
indexes: [
|
|
32
|
+
{ name: :primary, parts: [:id], sequence: true },
|
|
33
|
+
{ name: :by_code, parts: [:code] }
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
ensure_index :codes, :by_expiry, parts: [:expires_at], unique: false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The block runs with the definition as `self`, then the definition is frozen: calling `ensure_*` later raises
|
|
42
|
+
`FrozenError`, and loading the file again builds a new definition instead of growing the old one.
|
|
43
|
+
|
|
44
|
+
- `ensure_space(name, format:, indexes:, engine: :memtx)`. `format` uses Tarantool's own keys: `name`, `type`
|
|
45
|
+
(omitted means `any`), `is_nullable` (default false). `indexes` are in declaration order; the first one is the
|
|
46
|
+
primary key.
|
|
47
|
+
- Index keys: `name`, `parts`, `unique` (default true), `type` (`tree` by default, `hash`, `bitset`, `rtree`),
|
|
48
|
+
`sequence` (default false). A part is a field name or `{ field:, type:, is_nullable:, path: }`. A part's type
|
|
49
|
+
and nullability default to the field's; a part with a JSON `path` must declare its type.
|
|
50
|
+
- `ensure_index(space, name, **options)` adds an index to a space declared in the same definition. For a space
|
|
51
|
+
owned by someone else it only adds the index: the space must already exist, and part types come from its
|
|
52
|
+
live format.
|
|
53
|
+
|
|
54
|
+
### Validation
|
|
55
|
+
|
|
56
|
+
These raise `ArgumentError` while the definition is built, before anything reaches the server:
|
|
57
|
+
|
|
58
|
+
- a space declared twice, or declared after an `ensure_index` for it; a duplicate field or index name;
|
|
59
|
+
- unknown keys anywhere, including the unsupported options listed below;
|
|
60
|
+
- type aliases or unknown types. Only canonical names are accepted: `any unsigned string integer number double
|
|
61
|
+
boolean decimal uuid datetime interval varbinary scalar array map`. Use `string`, not `str`;
|
|
62
|
+
- a part that refers to a field missing from the format;
|
|
63
|
+
- vinyl spaces with anything but `tree` indexes;
|
|
64
|
+
- unique `rtree`/`bitset` indexes, and `rtree`/`bitset`, non-unique or nullable primary keys;
|
|
65
|
+
- `sequence: true` anywhere except a primary key with exactly one `unsigned` or `integer` part;
|
|
66
|
+
- `tree` or `hash` index parts of type `any`, `interval`, `array` or `map`; a `hash` index that is not unique, or
|
|
67
|
+
that has a nullable part (`tree` allows nullable parts).
|
|
68
|
+
|
|
69
|
+
### Not supported
|
|
70
|
+
|
|
71
|
+
Declaring any of these raises `ArgumentError`, so nobody assumes they were applied:
|
|
72
|
+
|
|
73
|
+
- space: `is_sync`, `temporary`, `is_local`, `field_count`, `id`, `user`, `constraint`, `foreign_key`;
|
|
74
|
+
- field: `default`, `default_func`, `constraint`, `foreign_key`, `collation`;
|
|
75
|
+
- index: `id`, `hint`, `func`, `dimension`, `distance`, vinyl tuning (`page_size`, `range_size`,
|
|
76
|
+
`run_count_per_level`, `run_size_ratio`, `bloom_fpr`);
|
|
77
|
+
- part: `collation`, `sort_order`, `exclude_null`.
|
|
78
|
+
|
|
79
|
+
Options that are set on a live space but that you did not declare are ignored when comparing.
|
|
80
|
+
|
|
81
|
+
## Applying
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
client = Tarantool::Client.new(ENV.fetch('TARANTOOL_ADMIN_URI'))
|
|
85
|
+
|
|
86
|
+
# codes already exists from an earlier deploy; only by_expiry is new
|
|
87
|
+
MyApp::SCHEMA.plan(client) # => ["index codes.by_expiry"], writes nothing
|
|
88
|
+
MyApp::SCHEMA.apply(client) # => ["index codes.by_expiry"]
|
|
89
|
+
MyApp::SCHEMA.apply(client) # => []
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Connect to the read-write instance. `apply`:
|
|
93
|
+
|
|
94
|
+
1. reads every declared space (one `eval` per space, with the name passed as an argument);
|
|
95
|
+
2. compares it with the declaration and collects the differences across all spaces;
|
|
96
|
+
3. if there are any, raises `Tarantool::Schema::MismatchError` and creates nothing;
|
|
97
|
+
4. creates the missing spaces and indexes in declaration order, with `if_not_exists` so two deploys racing each
|
|
98
|
+
other do not fail;
|
|
99
|
+
5. reads everything again and raises `MismatchError` if something is still missing or differs, for example
|
|
100
|
+
when another deploy created the space differently in the meantime. Racing another deploy can also surface
|
|
101
|
+
as `Tarantool::ServerError` raised directly from `create_index`, before this re-verification runs.
|
|
102
|
+
|
|
103
|
+
Each space and index is created with its own `eval`, so if one fails partway through (for example a privilege
|
|
104
|
+
missing for a later index), the ones created before it stay; running `apply` again is idempotent and picks up
|
|
105
|
+
where it left off.
|
|
106
|
+
|
|
107
|
+
Comparison is strict about what you declared: engine; format (field count, and for each position the name,
|
|
108
|
+
type and nullability); the primary index name; and for each declared index its type, uniqueness, sequence and
|
|
109
|
+
parts. Indexes in the live space that you did not declare are ignored. Type aliases in a live format (`str`,
|
|
110
|
+
`num`) count as their canonical names.
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
schema mismatch:
|
|
114
|
+
space codes:
|
|
115
|
+
engine: declared memtx, actual vinyl
|
|
116
|
+
field 2 code: type declared string, actual unsigned
|
|
117
|
+
index by_code: unique declared true, actual false
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`MismatchError#mismatches` returns the same lines as an Array, each prefixed with `space <name>: `.
|
|
121
|
+
|
|
122
|
+
## Rake
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
namespace :tarantool do
|
|
126
|
+
namespace :schema do
|
|
127
|
+
def with_schema_client
|
|
128
|
+
require 'tarantool/schema'
|
|
129
|
+
require_relative 'config/tarantool_schema' # defines MyApp::SCHEMA
|
|
130
|
+
client = Tarantool::Client.new(ENV.fetch('TARANTOOL_ADMIN_URI'))
|
|
131
|
+
yield client
|
|
132
|
+
ensure
|
|
133
|
+
client&.close
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
desc 'Show what tarantool:schema:apply would create; fail on drift'
|
|
137
|
+
task :check do
|
|
138
|
+
with_schema_client { |client| puts MyApp::SCHEMA.plan(client) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
desc 'Create missing Tarantool spaces and indexes; fail on drift'
|
|
142
|
+
task :apply do
|
|
143
|
+
with_schema_client { |client| puts MyApp::SCHEMA.apply(client) }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Privileges
|
|
150
|
+
|
|
151
|
+
The deploy user needs these privileges (tested on 2.11 and 3.x):
|
|
152
|
+
|
|
153
|
+
| Privilege | 2.11 | 3.x |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| eval | `execute` on `universe` | `execute` on `lua_eval` |
|
|
156
|
+
| create spaces | `create` on `space` | same |
|
|
157
|
+
| primary key sequences | `create` on `sequence` | same |
|
|
158
|
+
| system spaces | `read,write` on spaces `_space`, `_index`, `_sequence`, `_space_sequence` | same |
|
|
159
|
+
|
|
160
|
+
```lua
|
|
161
|
+
box.schema.user.create('deployer', {password = '...'})
|
|
162
|
+
box.schema.user.grant('deployer', 'execute', 'lua_eval') -- 2.11: 'execute', 'universe'
|
|
163
|
+
box.schema.user.grant('deployer', 'create', 'space')
|
|
164
|
+
box.schema.user.grant('deployer', 'create', 'sequence')
|
|
165
|
+
for _, name in ipairs({'_space', '_index', '_sequence', '_space_sequence'}) do
|
|
166
|
+
box.schema.user.grant('deployer', 'read,write', 'space', name)
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
On 2.11, only `admin` can grant privileges on `universe`; a `super` user cannot. Adding an index to a space
|
|
171
|
+
owned by another user also needs `alter` on that space. The simplest setup is a dedicated deploy user that owns
|
|
172
|
+
the application spaces, or one with the `super` role.
|
|
173
|
+
|
|
174
|
+
## Sharing the declaration with tests
|
|
175
|
+
|
|
176
|
+
`Definition#to_h` returns the normalized declaration: names as Symbols, types as Strings, every default filled
|
|
177
|
+
in, part types inherited, and `ensure_index` entries merged into their spaces. Use it to build an in-memory
|
|
178
|
+
test double or any other representation of the schema from the same declaration used to deploy it.
|
|
179
|
+
|
|
180
|
+
Only spaces declared with `ensure_space` are in it: indexes added with `ensure_index` to a space declared
|
|
181
|
+
elsewhere have no spec here and are not included.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
MyApp::SCHEMA.to_h
|
|
185
|
+
# => { codes: { engine: 'memtx',
|
|
186
|
+
# format: [{ name: :id, type: 'unsigned', is_nullable: false }, ...],
|
|
187
|
+
# indexes: [{ name: :primary, type: 'tree', unique: true, sequence: true,
|
|
188
|
+
# parts: [{ field: :id, type: 'unsigned', is_nullable: false, path: nil }] }, ...] } }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
tarantool-model's in-memory test storage accepts the definition as is, so tests run against the same declaration
|
|
192
|
+
as the deploy:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
require 'tarantool/model/testing'
|
|
196
|
+
|
|
197
|
+
storage = Tarantool::Model::Testing::MemoryStorage.from_definition(MyApp::SCHEMA)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
MemoryStorage raises `ArgumentError` for JSON `path` parts and `rtree` or `bitset` indexes.
|
|
201
|
+
|
|
202
|
+
## Errors
|
|
203
|
+
|
|
204
|
+
`Tarantool::Schema::MismatchError < Tarantool::Schema::Error < Tarantool::Error`. Server errors such as access
|
|
205
|
+
denied or a read-only instance are raised unchanged as `Tarantool::ServerError`.
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
podman compose -f tarantool-client/docker-compose.yml up -d --wait tarantool-2.11 tarantool-3
|
|
211
|
+
# or: docker compose -f tarantool-client/docker-compose.yml up -d --wait tarantool-2.11 tarantool-3
|
|
212
|
+
|
|
213
|
+
cd tarantool-schema
|
|
214
|
+
bundle exec rspec # unit
|
|
215
|
+
INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3301 bundle exec rspec
|
|
216
|
+
INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3302 bundle exec rspec
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Public API
|
|
220
|
+
|
|
221
|
+
`Tarantool::Schema::Definition` and the error classes above.
|
|
222
|
+
|
|
223
|
+
Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
|
|
224
|
+
and may change in any release.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Differences between one space as declared and as it is live. For a space declared elsewhere (only its
|
|
6
|
+
# indexes are declared here) there is no spec: engine and format are not compared and index parts inherit
|
|
7
|
+
# from the live format.
|
|
8
|
+
# @api private
|
|
9
|
+
class Comparison
|
|
10
|
+
attr_reader :name, :space, :mismatches, :missing_indexes
|
|
11
|
+
|
|
12
|
+
def initialize(name:, indexes:, live:, space: nil)
|
|
13
|
+
@name = name
|
|
14
|
+
@space = space
|
|
15
|
+
@live = live
|
|
16
|
+
@mismatches = []
|
|
17
|
+
@missing_indexes = []
|
|
18
|
+
live ? compare(indexes) : absent(indexes)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def missing_space? = @live.nil? && !@space.nil?
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def absent(indexes)
|
|
26
|
+
if @space
|
|
27
|
+
@missing_indexes.concat(indexes)
|
|
28
|
+
else
|
|
29
|
+
@mismatches << 'missing'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def compare(indexes)
|
|
34
|
+
compare_space if @space
|
|
35
|
+
indexes.each { |index| compare_index(index) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def compare_space
|
|
39
|
+
@mismatches << "engine: declared #{@space.engine}, actual #{@live.engine}" if @space.engine != @live.engine
|
|
40
|
+
@mismatches.concat(FormatComparison.new(@space.format, @live.format).mismatches)
|
|
41
|
+
compare_primary
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def compare_primary
|
|
45
|
+
declared = @space.indexes.first.name
|
|
46
|
+
actual = @live.primary&.name
|
|
47
|
+
@mismatches << "primary index: declared #{declared}, actual #{actual}" if actual && actual != declared
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def compare_index(index)
|
|
51
|
+
index = resolve(index) unless @space
|
|
52
|
+
return unless index
|
|
53
|
+
|
|
54
|
+
live = @live.index(index.name)
|
|
55
|
+
return @missing_indexes << index unless live
|
|
56
|
+
|
|
57
|
+
@mismatches.concat(IndexComparison.new(index, live).mismatches)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resolve(index)
|
|
61
|
+
fields = @live.fields
|
|
62
|
+
unknown = index.unknown_fields(fields)
|
|
63
|
+
return index.resolve(fields) if unknown.empty?
|
|
64
|
+
|
|
65
|
+
@mismatches << "index #{index.name}: unknown field #{unknown.join(', ')}"
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# What apply creates. Names and options are eval arguments, never part of the Lua source. if_not_exists keeps
|
|
6
|
+
# a deploy racing another one from failing; the re-verification after apply catches what the other one made.
|
|
7
|
+
# @api private
|
|
8
|
+
module Creation
|
|
9
|
+
SPACE_LUA = <<~LUA
|
|
10
|
+
local name, options = ...
|
|
11
|
+
box.schema.space.create(name, options)
|
|
12
|
+
LUA
|
|
13
|
+
|
|
14
|
+
INDEX_LUA = <<~LUA
|
|
15
|
+
local space, name, options = ...
|
|
16
|
+
box.space[space]:create_index(name, options)
|
|
17
|
+
LUA
|
|
18
|
+
|
|
19
|
+
Space = Data.define(:spec) do
|
|
20
|
+
def apply(client)
|
|
21
|
+
client.eval(SPACE_LUA, [spec.name.to_s, spec.to_lua.merge('if_not_exists' => true)])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_s = "space #{spec.name}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Index = Data.define(:space, :spec) do
|
|
28
|
+
def apply(client)
|
|
29
|
+
client.eval(INDEX_LUA, [space.to_s, spec.name.to_s, spec.to_lua.merge('if_not_exists' => true)])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_s = "index #{space}.#{spec.name}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Spaces and indexes an application needs. Built once from a block and frozen, so a definition kept in a
|
|
6
|
+
# constant cannot grow when its file is loaded again. The block runs with the definition as self.
|
|
7
|
+
class Definition
|
|
8
|
+
def initialize(&block)
|
|
9
|
+
@spaces = {}
|
|
10
|
+
@external = {}
|
|
11
|
+
instance_exec(&block) if block
|
|
12
|
+
@spaces.transform_values! { |space| SpaceRules.check(space.resolve) }.freeze
|
|
13
|
+
@external.each_value(&:freeze).freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ensure_space(name, format:, indexes:, engine: :memtx)
|
|
18
|
+
raise ArgumentError, 'space without name' if name.nil?
|
|
19
|
+
|
|
20
|
+
name = name.to_sym
|
|
21
|
+
raise ArgumentError, "space #{name} is already declared" if @spaces.key?(name)
|
|
22
|
+
raise ArgumentError, "space #{name}: declare it before its ensure_index" if @external.key?(name)
|
|
23
|
+
|
|
24
|
+
@spaces[name] = SpaceSpec.build(name, engine:, format:, indexes:)
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ensure_index(space, name, **options)
|
|
29
|
+
raise ArgumentError, 'space without name' if space.nil?
|
|
30
|
+
|
|
31
|
+
space = space.to_sym
|
|
32
|
+
index = IndexSpec.build(name, options)
|
|
33
|
+
if @spaces.key?(space)
|
|
34
|
+
@spaces[space] = @spaces[space].add_index(index)
|
|
35
|
+
else
|
|
36
|
+
add_external(space, index)
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h = @spaces.transform_values(&:to_h)
|
|
42
|
+
|
|
43
|
+
def plan(client) = build_plan(client).verify.creations.map(&:to_s)
|
|
44
|
+
|
|
45
|
+
# The second plan re-reads everything, so a space another deploy created differently in the meantime fails
|
|
46
|
+
# here instead of being reported as created.
|
|
47
|
+
def apply(client)
|
|
48
|
+
creations = build_plan(client).verify.creations
|
|
49
|
+
creations.each { |creation| creation.apply(client) }
|
|
50
|
+
build_plan(client).verify(complete: true)
|
|
51
|
+
|
|
52
|
+
creations.map(&:to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def build_plan(client) = Plan.new(spaces: @spaces, external: @external, client:)
|
|
58
|
+
|
|
59
|
+
# A space declared elsewhere already has its primary key, so an index added here never gets a sequence.
|
|
60
|
+
def add_external(space, index)
|
|
61
|
+
if index.sequence
|
|
62
|
+
raise ArgumentError, "index #{index.name}: sequence is only allowed on the primary index, declare it " \
|
|
63
|
+
'with ensure_space'
|
|
64
|
+
end
|
|
65
|
+
indexes = @external.fetch(space, [])
|
|
66
|
+
if indexes.any? { |i| i.name == index.name }
|
|
67
|
+
raise ArgumentError, "space #{space}: duplicate index #{index.name}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@external[space] = [*indexes, index]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Base class for errors raised by tarantool-schema.
|
|
6
|
+
class Error < Tarantool::Error; end
|
|
7
|
+
|
|
8
|
+
# The live schema differs from the declaration. apply raises it before creating anything, and again if what
|
|
9
|
+
# it created does not verify afterwards.
|
|
10
|
+
class MismatchError < Error
|
|
11
|
+
attr_reader :mismatches
|
|
12
|
+
|
|
13
|
+
def initialize(by_space)
|
|
14
|
+
@mismatches = by_space.flat_map { |space, lines| lines.map { |line| "space #{space}: #{line}" } }
|
|
15
|
+
super(describe(by_space))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def describe(by_space)
|
|
21
|
+
sections = by_space.map do |space, lines|
|
|
22
|
+
[" space #{space}:", *lines.map { |line| " #{line}" }].join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
"schema mismatch:\n#{sections.join("\n")}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# One entry of a space format with Tarantool's defaults filled in. Also used for live format entries.
|
|
6
|
+
# @api private
|
|
7
|
+
FieldSpec = Data.define(:name, :type, :is_nullable) do
|
|
8
|
+
def self.build(raw)
|
|
9
|
+
raise ArgumentError, "format entry must be a Hash, got #{raw.inspect}" unless raw.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
context = "field #{raw[:name]}"
|
|
12
|
+
Options.check(raw, %i[name type is_nullable], context)
|
|
13
|
+
raise ArgumentError, 'format entry without name' if raw[:name].nil?
|
|
14
|
+
|
|
15
|
+
new(name: raw[:name].to_sym, type: Types.declared(raw.fetch(:type, 'any'), context),
|
|
16
|
+
is_nullable: Options.boolean(raw.fetch(:is_nullable, false), "#{context} is_nullable"))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_lua = { 'name' => name.to_s, 'type' => type, 'is_nullable' => is_nullable }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Differences between a declared format and the live one, field by field in order.
|
|
6
|
+
# @api private
|
|
7
|
+
class FormatComparison
|
|
8
|
+
ATTRIBUTES = %i[name type is_nullable].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(declared, live)
|
|
11
|
+
@declared = declared
|
|
12
|
+
@live = live
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def mismatches
|
|
16
|
+
fields = @declared.zip(@live).each_with_index.flat_map do |(declared, live), index|
|
|
17
|
+
live ? field_mismatches(index + 1, declared, live) : []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
count_mismatch + fields
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def count_mismatch
|
|
26
|
+
return [] if @declared.size == @live.size
|
|
27
|
+
|
|
28
|
+
["format: declared #{@declared.size} fields, actual #{@live.size}"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def field_mismatches(number, declared, live)
|
|
32
|
+
ATTRIBUTES.filter_map do |attribute|
|
|
33
|
+
expected = declared.public_send(attribute)
|
|
34
|
+
actual = live.public_send(attribute)
|
|
35
|
+
"field #{number} #{declared.name}: #{attribute} declared #{expected}, actual #{actual}" if expected != actual
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Differences between a declared index, its parts resolved, and the live index of the same name.
|
|
6
|
+
# @api private
|
|
7
|
+
class IndexComparison
|
|
8
|
+
ATTRIBUTES = %i[type unique sequence].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(declared, live)
|
|
11
|
+
@declared = declared
|
|
12
|
+
@live = live
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def mismatches
|
|
16
|
+
ATTRIBUTES.filter_map { |attribute| attribute_mismatch(attribute) } + parts_mismatch
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def attribute_mismatch(attribute)
|
|
22
|
+
expected = @declared.public_send(attribute)
|
|
23
|
+
actual = @live.public_send(attribute)
|
|
24
|
+
|
|
25
|
+
"index #{@declared.name}: #{attribute} declared #{expected}, actual #{actual}" if expected != actual
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parts_mismatch
|
|
29
|
+
return [] if @declared.parts == @live.parts
|
|
30
|
+
|
|
31
|
+
["index #{@declared.name}: parts declared #{describe(@declared.parts)}, actual #{describe(@live.parts)}"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def describe(parts)
|
|
35
|
+
parts.map { |part| [part.field, part.path, part.type, ('nullable' if part.is_nullable)].compact.join(' ') }
|
|
36
|
+
.join(', ')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# A declared index with create_index defaults filled in. Rules that depend on the space (primary key,
|
|
6
|
+
# engine, sequence) live in SpaceRules.
|
|
7
|
+
# @api private
|
|
8
|
+
IndexSpec = Data.define(:name, :type, :unique, :parts, :sequence) do
|
|
9
|
+
def self.build(name, raw)
|
|
10
|
+
raise ArgumentError, 'index without name' if name.nil?
|
|
11
|
+
|
|
12
|
+
context = "index #{name}"
|
|
13
|
+
Options.check(raw, %i[parts unique type sequence], context)
|
|
14
|
+
index = new(name: name.to_sym, type: type(raw, context), parts: parts(raw[:parts], context),
|
|
15
|
+
unique: Options.boolean(raw.fetch(:unique, true), "#{context} unique"),
|
|
16
|
+
sequence: Options.boolean(raw.fetch(:sequence, false), "#{context} sequence"))
|
|
17
|
+
check_unique(index, context)
|
|
18
|
+
KeyRules.check(index, nil)
|
|
19
|
+
|
|
20
|
+
index
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.type(raw, context)
|
|
24
|
+
type = raw.fetch(:type, :tree).to_s
|
|
25
|
+
raise ArgumentError, "#{context}: unknown type #{type.inspect}" unless IndexSpec::TYPES.include?(type)
|
|
26
|
+
|
|
27
|
+
-type
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parts(raw, context)
|
|
31
|
+
raise ArgumentError, "#{context}: parts must be a non-empty Array" unless raw.is_a?(Array) && raw.any?
|
|
32
|
+
|
|
33
|
+
raw.map { |part| PartSpec.build(part) }.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.check_unique(index, context)
|
|
37
|
+
return unless index.unique && IndexSpec::NON_UNIQUE.include?(index.type)
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "#{context}: #{index.type} index can not be unique, set unique: false"
|
|
40
|
+
end
|
|
41
|
+
private_class_method :type, :parts, :check_unique
|
|
42
|
+
|
|
43
|
+
def unknown_fields(fields) = parts.map(&:field).reject { |field| fields.key?(field) }
|
|
44
|
+
|
|
45
|
+
def resolve(fields) = with(parts: parts.map { |part| part.inherit(fields.fetch(part.field)) }.freeze)
|
|
46
|
+
|
|
47
|
+
def to_h = super.merge(parts: parts.map(&:to_h))
|
|
48
|
+
|
|
49
|
+
def to_lua = { 'type' => type, 'unique' => unique, 'parts' => parts.map(&:to_lua), 'sequence' => sequence }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
IndexSpec::TYPES = %w[tree hash bitset rtree].freeze
|
|
53
|
+
IndexSpec::NON_UNIQUE = %w[bitset rtree].freeze
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Reads one space from box.space. The name travels as an eval argument, never inside the Lua source.
|
|
6
|
+
# @api private
|
|
7
|
+
module Inspector
|
|
8
|
+
LUA = <<~LUA
|
|
9
|
+
local space = box.space[...]
|
|
10
|
+
if space == nil then return nil end
|
|
11
|
+
local indexes = {}
|
|
12
|
+
for id, index in pairs(space.index) do
|
|
13
|
+
if type(id) == 'number' then
|
|
14
|
+
local parts = {}
|
|
15
|
+
for _, part in ipairs(index.parts) do
|
|
16
|
+
table.insert(parts, {fieldno = part.fieldno, type = part.type, is_nullable = part.is_nullable,
|
|
17
|
+
path = part.path})
|
|
18
|
+
end
|
|
19
|
+
table.insert(indexes, {id = id, name = index.name, type = index.type, unique = index.unique,
|
|
20
|
+
parts = parts, sequence = index.sequence_id ~= nil})
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
return {engine = space.engine, format = space:format(), indexes = indexes}
|
|
24
|
+
LUA
|
|
25
|
+
|
|
26
|
+
def self.call(client, name)
|
|
27
|
+
raw = client.eval(LUA, [name.to_s]).first
|
|
28
|
+
|
|
29
|
+
raw && LiveSpace.from_eval(raw)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Key part rules Tarantool enforces on tree and hash indexes, verified live on 2.11 and 3.x. Checked before
|
|
6
|
+
# resolution, on what IndexSpec.build already knows, and again after, on a resolved SpaceSpec where parts
|
|
7
|
+
# have inherited their type and nullability from the format.
|
|
8
|
+
# @api private
|
|
9
|
+
module KeyRules
|
|
10
|
+
UNSUPPORTED_PART_TYPES = %w[any interval array map].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def check(index, context)
|
|
15
|
+
return unless %w[tree hash].include?(index.type)
|
|
16
|
+
|
|
17
|
+
index.parts.each { |part| check_part_type(index, part, context) }
|
|
18
|
+
check_unique(index, context)
|
|
19
|
+
index.parts.each { |part| check_nullable(index, part, context) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check_part_type(index, part, context)
|
|
23
|
+
return unless part.type && UNSUPPORTED_PART_TYPES.include?(part.type)
|
|
24
|
+
|
|
25
|
+
raise error(index, context, "part #{part.field} of type #{part.type} can not be indexed by #{index.type}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_unique(index, context)
|
|
29
|
+
return unless index.type == 'hash' && !index.unique
|
|
30
|
+
|
|
31
|
+
raise error(index, context, 'hash index must be unique')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check_nullable(index, part, context)
|
|
35
|
+
return unless index.type == 'hash' && part.is_nullable
|
|
36
|
+
|
|
37
|
+
raise error(index, context, "hash index can not have nullable part #{part.field}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def error(index, context, message)
|
|
41
|
+
ArgumentError.new("#{"#{context}: " if context}index #{index.name}: #{message}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# A space as the server has it, normalized to compare with a declaration: type aliases mapped, index types
|
|
6
|
+
# downcased, missing nullability read as false. An empty Lua table may arrive as a map, hence Array().
|
|
7
|
+
# @api private
|
|
8
|
+
LiveSpace = Data.define(:engine, :format, :indexes) do
|
|
9
|
+
def self.from_eval(raw)
|
|
10
|
+
format = Array(raw['format']).map do |field|
|
|
11
|
+
FieldSpec.new(name: field.fetch('name').to_sym, type: Types.live(field.fetch('type', 'any')),
|
|
12
|
+
is_nullable: field.fetch('is_nullable', false))
|
|
13
|
+
end
|
|
14
|
+
indexes = Array(raw['indexes']).map { |index| LiveSpace::Index.from_eval(index, format) }
|
|
15
|
+
|
|
16
|
+
new(engine: raw.fetch('engine'), format:, indexes: indexes.sort_by(&:id))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def index(name) = indexes.find { |index| index.name == name }
|
|
20
|
+
|
|
21
|
+
def primary = indexes.find { |index| index.id.zero? }
|
|
22
|
+
|
|
23
|
+
def fields = format.to_h { |field| [field.name, field] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
LiveSpace::Index = Data.define(:id, :name, :type, :unique, :parts, :sequence) do
|
|
27
|
+
def self.from_eval(raw, format)
|
|
28
|
+
new(id: raw.fetch('id'), name: raw.fetch('name').to_sym, type: raw.fetch('type').downcase,
|
|
29
|
+
unique: raw.fetch('unique'), sequence: raw.fetch('sequence'),
|
|
30
|
+
parts: Array(raw['parts']).map { |part| part_from_eval(part, format) })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# fieldno is 1-based; a part beyond the format has no field name.
|
|
34
|
+
def self.part_from_eval(raw, format)
|
|
35
|
+
fieldno = raw.fetch('fieldno')
|
|
36
|
+
|
|
37
|
+
PartSpec.new(field: format[fieldno - 1]&.name || :"##{fieldno}", type: Types.live(raw.fetch('type')),
|
|
38
|
+
is_nullable: raw.fetch('is_nullable', false), path: raw['path'])
|
|
39
|
+
end
|
|
40
|
+
private_class_method :part_from_eval
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Declaration keys are checked against a closed list, so an unsupported Tarantool option such as is_sync
|
|
6
|
+
# fails loudly instead of looking applied.
|
|
7
|
+
# @api private
|
|
8
|
+
module Options
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def check(given, allowed, context)
|
|
12
|
+
unknown = given.keys - allowed
|
|
13
|
+
return if unknown.empty?
|
|
14
|
+
|
|
15
|
+
raise ArgumentError, "#{context}: unsupported #{unknown.map(&:inspect).join(', ')}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def boolean(value, context)
|
|
19
|
+
return value if [true, false].include?(value)
|
|
20
|
+
|
|
21
|
+
raise ArgumentError, "#{context}: expected true or false, got #{value.inspect}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# One part of an index key. Type and nullability left out stay nil until #inherit copies them from the
|
|
6
|
+
# field, as Tarantool does (verified for JSON path parts too). A path points inside the field, so its type
|
|
7
|
+
# cannot come from the field and must be declared.
|
|
8
|
+
# @api private
|
|
9
|
+
PartSpec = Data.define(:field, :type, :is_nullable, :path) do
|
|
10
|
+
def self.build(raw)
|
|
11
|
+
raw = { field: raw } unless raw.is_a?(Hash)
|
|
12
|
+
context = "index part #{raw[:field]}"
|
|
13
|
+
Options.check(raw, %i[field type is_nullable path], context)
|
|
14
|
+
raise ArgumentError, "#{context}: a part with path needs a type" if raw[:path] && !raw[:type]
|
|
15
|
+
raise ArgumentError, 'index part without field' if raw[:field].nil?
|
|
16
|
+
|
|
17
|
+
new(**attributes(raw, context))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.attributes(raw, context)
|
|
21
|
+
{ field: raw[:field].to_sym,
|
|
22
|
+
type: raw[:type] && Types.declared(raw[:type], context),
|
|
23
|
+
is_nullable: raw[:is_nullable].nil? ? nil : Options.boolean(raw[:is_nullable], "#{context} is_nullable"),
|
|
24
|
+
path: raw[:path] && -raw[:path].to_s }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private_class_method :attributes
|
|
28
|
+
|
|
29
|
+
def inherit(field)
|
|
30
|
+
with(type: type || field.type, is_nullable: is_nullable.nil? ? field.is_nullable : is_nullable)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_lua = { 'field' => field.to_s, 'type' => type, 'is_nullable' => is_nullable, 'path' => path }.compact
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# The live state of everything a definition declares, compared with it, and what is left to create.
|
|
6
|
+
# @api private
|
|
7
|
+
class Plan
|
|
8
|
+
def initialize(spaces:, external:, client:)
|
|
9
|
+
@comparisons = spaces.values.map { |space| compare(client, space.name, space.indexes, space) } +
|
|
10
|
+
external.map { |name, indexes| compare(client, name, indexes) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# complete: anything still missing is a mismatch too, used after apply has created it.
|
|
14
|
+
def verify(complete: false)
|
|
15
|
+
by_space = @comparisons.to_h do |comparison|
|
|
16
|
+
[comparison.name, comparison.mismatches + (complete ? still_missing(comparison) : [])]
|
|
17
|
+
end
|
|
18
|
+
by_space.reject! { |_, lines| lines.empty? }
|
|
19
|
+
raise MismatchError, by_space if by_space.any?
|
|
20
|
+
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def creations
|
|
25
|
+
@comparisons.flat_map do |comparison|
|
|
26
|
+
space = comparison.missing_space? ? [Creation::Space.new(spec: comparison.space)] : []
|
|
27
|
+
|
|
28
|
+
space + comparison.missing_indexes.map { |index| Creation::Index.new(space: comparison.name, spec: index) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def compare(client, name, indexes, space = nil)
|
|
35
|
+
Comparison.new(name:, indexes:, live: Inspector.call(client, name), space:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def still_missing(comparison)
|
|
39
|
+
return ['missing after apply'] if comparison.missing_space?
|
|
40
|
+
|
|
41
|
+
comparison.missing_indexes.map { |index| "index #{index.name}: missing after apply" }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Rules Tarantool enforces on a whole space, checked on a resolved SpaceSpec before anything reaches the
|
|
6
|
+
# server.
|
|
7
|
+
# @api private
|
|
8
|
+
class SpaceRules
|
|
9
|
+
SEQUENCE_TYPES = %w[unsigned integer].freeze
|
|
10
|
+
|
|
11
|
+
def self.check(space) = new(space).check
|
|
12
|
+
|
|
13
|
+
def initialize(space)
|
|
14
|
+
@space = space
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check
|
|
18
|
+
raise error('at least one index is required') if @space.indexes.empty?
|
|
19
|
+
|
|
20
|
+
check_primary(@space.indexes.first)
|
|
21
|
+
check_no_secondary_sequence
|
|
22
|
+
check_engine
|
|
23
|
+
@space.indexes.each { |index| KeyRules.check(index, "space #{@space.name}") }
|
|
24
|
+
|
|
25
|
+
@space
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def check_no_secondary_sequence
|
|
31
|
+
extra = @space.indexes.drop(1).find(&:sequence)
|
|
32
|
+
return unless extra
|
|
33
|
+
|
|
34
|
+
raise error("index #{extra.name}: sequence is only allowed on the primary index")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_primary(index)
|
|
38
|
+
check_primary_type(index)
|
|
39
|
+
check_primary_unique(index)
|
|
40
|
+
check_primary_nullable(index)
|
|
41
|
+
check_sequence(index) if index.sequence
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check_primary_type(index)
|
|
45
|
+
return unless IndexSpec::NON_UNIQUE.include?(index.type)
|
|
46
|
+
|
|
47
|
+
raise error("primary index #{index.name} can not be #{index.type}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check_primary_unique(index)
|
|
51
|
+
raise error("primary index #{index.name} must be unique") unless index.unique
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_primary_nullable(index)
|
|
55
|
+
nullable = index.parts.find(&:is_nullable)
|
|
56
|
+
raise error("primary index #{index.name}: part #{nullable.field} can not be nullable") if nullable
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_sequence(index)
|
|
60
|
+
return if index.parts.size == 1 && SEQUENCE_TYPES.include?(index.parts.first.type)
|
|
61
|
+
|
|
62
|
+
raise error("index #{index.name}: sequence needs a single unsigned or integer part")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_engine
|
|
66
|
+
return if @space.engine == 'memtx'
|
|
67
|
+
|
|
68
|
+
other = @space.indexes.reject { |index| index.type == 'tree' }
|
|
69
|
+
raise error("vinyl supports only tree indexes, got #{other.map(&:name).join(', ')}") if other.any?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def error(message) = ArgumentError.new("space #{@space.name}: #{message}")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# A declared space. Its index parts get the field types and nullability once #resolve runs.
|
|
6
|
+
# @api private
|
|
7
|
+
SpaceSpec = Data.define(:name, :engine, :format, :indexes) do
|
|
8
|
+
def self.build(name, engine:, format:, indexes:)
|
|
9
|
+
raise ArgumentError, 'space without name' if name.nil?
|
|
10
|
+
|
|
11
|
+
context = "space #{name}"
|
|
12
|
+
space = new(name: name.to_sym, engine: build_engine(engine, context), format: build_format(format, context),
|
|
13
|
+
indexes: [].freeze)
|
|
14
|
+
|
|
15
|
+
build_indexes(space, indexes, context)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.build_engine(engine, context)
|
|
19
|
+
engine = engine.to_s
|
|
20
|
+
raise ArgumentError, "#{context}: unknown engine #{engine.inspect}" unless SpaceSpec::ENGINES.include?(engine)
|
|
21
|
+
|
|
22
|
+
-engine
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.build_indexes(space, indexes, context)
|
|
26
|
+
raise ArgumentError, "#{context}: indexes must be an Array" unless indexes.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
indexes.reduce(space) { |result, raw| result.add_index(IndexSpec.build(*index_arguments(raw, context))) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.build_format(raw, context)
|
|
32
|
+
raise ArgumentError, "#{context}: format must be an Array" unless raw.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
fields = raw.map { |field| FieldSpec.build(field) }
|
|
35
|
+
duplicates = fields.map(&:name).tally.select { |_, count| count > 1 }.keys
|
|
36
|
+
raise ArgumentError, "#{context}: duplicate field #{duplicates.join(', ')}" if duplicates.any?
|
|
37
|
+
|
|
38
|
+
fields.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.index_arguments(raw, context)
|
|
42
|
+
raise ArgumentError, "#{context}: index must be a Hash, got #{raw.inspect}" unless raw.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
[raw.fetch(:name) { raise ArgumentError, "#{context}: index without name" }, raw.except(:name)]
|
|
45
|
+
end
|
|
46
|
+
private_class_method :build_engine, :build_indexes, :build_format, :index_arguments
|
|
47
|
+
|
|
48
|
+
def add_index(index)
|
|
49
|
+
raise ArgumentError, "space #{name}: duplicate index #{index.name}" if indexes.any? { |i| i.name == index.name }
|
|
50
|
+
|
|
51
|
+
with(indexes: [*indexes, index].freeze)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve
|
|
55
|
+
fields = format.to_h { |field| [field.name, field] }
|
|
56
|
+
unknown = unknown_field_errors(fields)
|
|
57
|
+
raise ArgumentError, "space #{name}: #{unknown.join('; ')}" if unknown.any?
|
|
58
|
+
|
|
59
|
+
with(indexes: indexes.map { |index| index.resolve(fields) }.freeze)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_h = { engine:, format: format.map(&:to_h), indexes: indexes.map(&:to_h) }
|
|
63
|
+
|
|
64
|
+
def to_lua = { 'engine' => engine, 'format' => format.map(&:to_lua) }
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def unknown_field_errors(fields)
|
|
69
|
+
indexes.flat_map do |index|
|
|
70
|
+
index.unknown_fields(fields).map { |field| "index #{index.name}: unknown field #{field}" }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
SpaceSpec::ENGINES = %w[memtx vinyl].freeze
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarantool
|
|
4
|
+
module Schema
|
|
5
|
+
# Field type names. The server keeps a space format type verbatim (str stays str) but normalizes index part
|
|
6
|
+
# types (str becomes string), so declarations accept only canonical names and live format types are mapped
|
|
7
|
+
# onto them before comparing.
|
|
8
|
+
# @api private
|
|
9
|
+
module Types
|
|
10
|
+
CANONICAL = %w[
|
|
11
|
+
any unsigned string integer number double boolean decimal uuid datetime interval varbinary scalar array map
|
|
12
|
+
].freeze
|
|
13
|
+
ALIASES = { 'str' => 'string', 'num' => 'number' }.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def declared(type, context)
|
|
18
|
+
name = type.to_s
|
|
19
|
+
return -name if CANONICAL.include?(name)
|
|
20
|
+
|
|
21
|
+
hint = canonical(name)
|
|
22
|
+
raise ArgumentError, "#{context}: unknown type #{name.inspect}#{", use #{hint}" if hint}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def live(type)
|
|
26
|
+
name = type.to_s
|
|
27
|
+
|
|
28
|
+
canonical(name) || name
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def canonical(name)
|
|
32
|
+
lower = name.downcase
|
|
33
|
+
|
|
34
|
+
ALIASES.fetch(lower) { lower if CANONICAL.include?(lower) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tarantool/client'
|
|
4
|
+
|
|
5
|
+
require_relative 'schema/version'
|
|
6
|
+
require_relative 'schema/errors'
|
|
7
|
+
require_relative 'schema/options'
|
|
8
|
+
require_relative 'schema/types'
|
|
9
|
+
require_relative 'schema/key_rules'
|
|
10
|
+
require_relative 'schema/field_spec'
|
|
11
|
+
require_relative 'schema/part_spec'
|
|
12
|
+
require_relative 'schema/index_spec'
|
|
13
|
+
require_relative 'schema/space_spec'
|
|
14
|
+
require_relative 'schema/space_rules'
|
|
15
|
+
require_relative 'schema/plan'
|
|
16
|
+
require_relative 'schema/definition'
|
|
17
|
+
require_relative 'schema/live_space'
|
|
18
|
+
require_relative 'schema/inspector'
|
|
19
|
+
require_relative 'schema/format_comparison'
|
|
20
|
+
require_relative 'schema/index_comparison'
|
|
21
|
+
require_relative 'schema/comparison'
|
|
22
|
+
require_relative 'schema/creation'
|
|
23
|
+
|
|
24
|
+
module Tarantool
|
|
25
|
+
# Idempotent declaration of spaces, indexes and primary key sequences, applied over an admin connection.
|
|
26
|
+
module Schema
|
|
27
|
+
end
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tarantool-schema
|
|
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-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: tarantool-client
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.2.0
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 0.2.0
|
|
27
|
+
description: Declare Tarantool 2.11+ and 3.x spaces, indexes and primary key sequences
|
|
28
|
+
in Ruby; apply creates what is missing and fails with a readable diff when the live
|
|
29
|
+
schema differs.
|
|
30
|
+
email:
|
|
31
|
+
- apavlovskiy088@gmail.com
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- LICENSE
|
|
38
|
+
- README.md
|
|
39
|
+
- lib/tarantool/schema.rb
|
|
40
|
+
- lib/tarantool/schema/comparison.rb
|
|
41
|
+
- lib/tarantool/schema/creation.rb
|
|
42
|
+
- lib/tarantool/schema/definition.rb
|
|
43
|
+
- lib/tarantool/schema/errors.rb
|
|
44
|
+
- lib/tarantool/schema/field_spec.rb
|
|
45
|
+
- lib/tarantool/schema/format_comparison.rb
|
|
46
|
+
- lib/tarantool/schema/index_comparison.rb
|
|
47
|
+
- lib/tarantool/schema/index_spec.rb
|
|
48
|
+
- lib/tarantool/schema/inspector.rb
|
|
49
|
+
- lib/tarantool/schema/key_rules.rb
|
|
50
|
+
- lib/tarantool/schema/live_space.rb
|
|
51
|
+
- lib/tarantool/schema/options.rb
|
|
52
|
+
- lib/tarantool/schema/part_spec.rb
|
|
53
|
+
- lib/tarantool/schema/plan.rb
|
|
54
|
+
- lib/tarantool/schema/space_rules.rb
|
|
55
|
+
- lib/tarantool/schema/space_spec.rb
|
|
56
|
+
- lib/tarantool/schema/types.rb
|
|
57
|
+
- lib/tarantool/schema/version.rb
|
|
58
|
+
homepage: https://github.com/Starlexxx/tarantool-ruby
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
source_code_uri: https://github.com/Starlexxx/tarantool-ruby/tree/master/tarantool-schema
|
|
63
|
+
changelog_uri: https://github.com/Starlexxx/tarantool-ruby/blob/master/tarantool-schema/CHANGELOG.md
|
|
64
|
+
bug_tracker_uri: https://github.com/Starlexxx/tarantool-ruby/issues
|
|
65
|
+
homepage_uri: https://github.com/Starlexxx/tarantool-ruby
|
|
66
|
+
rubygems_mfa_required: 'true'
|
|
67
|
+
post_install_message:
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.2'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.5.22
|
|
83
|
+
signing_key:
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: Idempotent Tarantool schema declaration for deploys
|
|
86
|
+
test_files: []
|