tarantool-schema 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f89be3eca7d9896490c3672d0273f45e8111b884ddabcdeb057f9b2f4838761
4
- data.tar.gz: a8302d61a037f384ebf985a1105d7c4a7f08ff6f703b57b1dc33e72311a40678
3
+ metadata.gz: daa090a4546c7ecc8ed5c5e62f1a465b72e828c434e34076411069b6967a1914
4
+ data.tar.gz: 87f6b699e61c2e935f2dd508413910b89d333a1d302715bad446d9f5c5e90dbe
5
5
  SHA512:
6
- metadata.gz: 4fb8b7cb681afef414ed5f7b4490f93b3b3ddd15dd4366076c7f0ccaba1f9a9eb0c05e2c7940dd4dec1f5e03514d783d5f4568cbf5411827f570e63678b17687
7
- data.tar.gz: 8f7926a4f6eb1a9518534adb95cc2fdf5ed4b155bb508532502efdf442ebacc9d942cfafb5ca37418383e8091c68da3fdea37f19750a58c180c956d800137eb4
6
+ metadata.gz: c15dc87c94a1f8e24a537d53b63c84807e49436e34e4a24e1991184fed86d6f4c3abd0c40b6f6f5eb430762cc5954def2dea763f872e987fe459ac6635b0cc05
7
+ data.tar.gz: 85e8b8b6adfad1f6d9e61099991df5ee3001d0aa6c84e436a6c74faa5f1c38834536ff659ade1ef38d279fc1d2f89863f5557cc0ef6a005eb59b564b1ed30928
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-10-18)
4
+
5
+ - `grant user, privileges, on: space` declares the privileges a user or role must have on a space. `plan` and
6
+ `apply` grant what is missing after the space and its indexes, keep extra privileges, never revoke, and raise
7
+ `MismatchError` for a grantee that does not exist. `from_live` reads grants, `to_ruby` renders them,
8
+ `Definition#grants` lists them. The deploy user needs `read,write` on `_priv` and `read` on `_user` for this.
9
+
3
10
  ## 0.2.1 (2026-10-17)
4
11
 
5
12
  - Depends on `tarantool-client ~> 0.3`.
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # tarantool-schema
2
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.
3
+ Idempotent schema declaration for Tarantool 2.11+ and 3.x, meant for deploys. You declare spaces, indexes,
4
+ primary key sequences and grants in Ruby; `apply` creates whatever is missing and checks everything that already
5
+ exists.
5
6
  If the live schema differs from the declaration, it raises an error with a diff and creates nothing. It alters
6
7
  only what you allow (appending nullable fields) and never drops anything: changing a field type or rebuilding an
7
8
  index is a migration and is out of scope. An existing database can be read back into the DSL with `from_live`.
@@ -35,6 +36,8 @@ module MyApp
35
36
  ]
36
37
 
37
38
  ensure_index :codes, :by_expiry, parts: [:expires_at], unique: false
39
+
40
+ grant :app, %i[read write], on: :codes
38
41
  end
39
42
  end
40
43
  ```
@@ -51,12 +54,14 @@ The block runs with the definition as `self`, then the definition is frozen: cal
51
54
  - `ensure_index(space, name, **options)` adds an index to a space declared in the same definition. For a space
52
55
  owned by someone else it only adds the index: the space must already exist, and part types come from its
53
56
  live format.
57
+ - `grant(user, privileges, on:)` declares the privileges a user or a role must have on a space, see Grants.
54
58
 
55
59
  ### Validation
56
60
 
57
61
  These raise `ArgumentError` while the definition is built, before anything reaches the server:
58
62
 
59
- - a space declared twice, or declared after an `ensure_index` for it; a duplicate field or index name;
63
+ - a space declared twice, or declared after an `ensure_index` for it; a duplicate field or index name; the same
64
+ grantee twice on one space;
60
65
  - unknown keys anywhere, including the unsupported options listed below;
61
66
  - type aliases or unknown types. Only canonical names are accepted: `any unsigned string integer number double
62
67
  boolean decimal uuid datetime interval varbinary scalar array map`. Use `string`, not `str`;
@@ -75,7 +80,9 @@ Declaring any of these raises `ArgumentError`, so nobody assumes they were appli
75
80
  - field: `default`, `default_func`, `constraint`, `foreign_key`, `collation`;
76
81
  - index: `id`, `hint`, `func`, `dimension`, `distance`, vinyl tuning (`page_size`, `range_size`,
77
82
  `run_count_per_level`, `run_size_ratio`, `bloom_fpr`);
78
- - part: `collation`, `sort_order`, `exclude_null`.
83
+ - part: `collation`, `sort_order`, `exclude_null`;
84
+ - grant: any privilege but `read write usage create drop alter trigger insert update delete` (`execute` on a
85
+ space exists on 2.11 only).
79
86
 
80
87
  Options that are set on a live space but that you did not declare are ignored when comparing.
81
88
 
@@ -108,7 +115,7 @@ where it left off.
108
115
  Comparison is strict about what you declared: engine; format (field count, and for each position the name,
109
116
  type and nullability); the primary index name; and for each declared index its type, uniqueness, sequence and
110
117
  parts. Indexes in the live space that you did not declare are ignored. Type aliases in a live format (`str`,
111
- `num`) count as their canonical names.
118
+ `num`) count as their canonical names. Grants are compared as described under Grants.
112
119
 
113
120
  ```
114
121
  schema mismatch:
@@ -141,6 +148,26 @@ Everything else stays a mismatch: a field that is not nullable (`field 4 flag: m
141
148
  added`), a live format longer than the declared one, a field differing inside the prefix. Nothing is ever
142
149
  dropped.
143
150
 
151
+ ## Grants
152
+
153
+ Privileges on spaces belong in the schema: in Tarantool 3.x the YAML configuration grants them only to spaces
154
+ that exist when the configuration is applied, so a space created by a later deploy is left without. A grant
155
+ names a user or a role, the privileges and the space:
156
+
157
+ ```ruby
158
+ grant :app, %i[read write], on: :codes
159
+ grant :reporting, :read, on: :codes
160
+ ```
161
+
162
+ `plan` and `apply` treat a grant like an index: after the space and its indexes they grant what is missing,
163
+ only the privileges still lacking (`grant app write on codes` when `read` is already there). Privileges granted
164
+ but not declared are kept, nothing is ever revoked. A grantee that does not exist is a mismatch (`grant app: no
165
+ such user or role`), raised before anything is created; users and roles are not managed here. A grant on a space
166
+ that is neither declared nor live is a mismatch too (`missing`). Privileges: `read write usage create drop alter
167
+ trigger insert update delete`.
168
+
169
+ The deploy user needs `read,write` on `_priv` and `read` on `_user` for grants, see Privileges.
170
+
144
171
  ## Bootstrapping from a live schema
145
172
 
146
173
  ```ruby
@@ -153,14 +180,16 @@ File.write('config/tarantool_schema.rb', definition.to_ruby)
153
180
  `from_live` reads the spaces with ids past the system range whose names do not start with `_` (vshard's
154
181
  `_bucket` and crud's `_ddl_*` stay out); `only:` names the spaces to read instead, with or without a leading
155
182
  underscore, and raises `ArgumentError` for a name that does not exist. Each space is read as `ensure_space`
156
- would declare it: engine, format, indexes in id order with their parts. A part that inherits its type and
183
+ would declare it: engine, format, indexes in id order with their parts, then the grants on it (a privilege the
184
+ DSL does not know, `execute` on 2.11, is left out). A part that inherits its type and
157
185
  nullability from the field is a bare field name; a JSON path part keeps its type. Index options the DSL does not
158
186
  know (`hint`, collations, `exclude_null`, vinyl tuning) are not read, as `plan` ignores them anyway. A
159
187
  functional index, or a part pointing past the format, raises `ArgumentError` naming the space and index: the
160
188
  DSL cannot express them.
161
189
 
162
190
  `to_ruby` renders a definition as source for a `Tarantool::Schema::Definition.new do ... end` block, defaults
163
- left out, one space per `ensure_space`, `ensure_index` entries after them; it evaluates back to the same `to_h`:
191
+ left out, one space per `ensure_space`, `ensure_index` entries after them, then one `grant` per line; it
192
+ evaluates back to the same `to_h` and `grants`:
164
193
 
165
194
  ```ruby
166
195
  Tarantool::Schema::Definition.new do
@@ -175,6 +204,8 @@ Tarantool::Schema::Definition.new do
175
204
  { name: :by_code, parts: [:code] },
176
205
  { name: :by_expiry, parts: [:expires_at], unique: false }
177
206
  ]
207
+
208
+ grant :app, %i[read write], on: :codes
178
209
  end
179
210
  ```
180
211
 
@@ -215,15 +246,17 @@ The deploy user needs these privileges (tested on 2.11 and 3.x):
215
246
  | create spaces | `create` on `space` | same |
216
247
  | primary key sequences | `create` on `sequence` | same |
217
248
  | system spaces | `read,write` on spaces `_space`, `_index`, `_sequence`, `_space_sequence` | same |
249
+ | grants | `read,write` on space `_priv`, `read` on space `_user` | same |
218
250
 
219
251
  ```lua
220
252
  box.schema.user.create('deployer', {password = '...'})
221
253
  box.schema.user.grant('deployer', 'execute', 'lua_eval') -- 2.11: 'execute', 'universe'
222
254
  box.schema.user.grant('deployer', 'create', 'space')
223
255
  box.schema.user.grant('deployer', 'create', 'sequence')
224
- for _, name in ipairs({'_space', '_index', '_sequence', '_space_sequence'}) do
256
+ for _, name in ipairs({'_space', '_index', '_sequence', '_space_sequence', '_priv'}) do
225
257
  box.schema.user.grant('deployer', 'read,write', 'space', name)
226
258
  end
259
+ box.schema.user.grant('deployer', 'read', 'space', '_user')
227
260
  ```
228
261
 
229
262
  On 2.11, only `admin` can grant privileges on `universe`; a `super` user cannot. Adding an index to a space
@@ -237,7 +270,8 @@ in, part types inherited, and `ensure_index` entries merged into their spaces. U
237
270
  test double or any other representation of the schema from the same declaration used to deploy it.
238
271
 
239
272
  Only spaces declared with `ensure_space` are in it: indexes added with `ensure_index` to a space declared
240
- elsewhere have no spec here and are not included.
273
+ elsewhere have no spec here and are not included. Grants are not in it either; `Definition#grants` lists them
274
+ as `[{ user: :app, privileges: ['read', 'write'], space: :codes }]`.
241
275
 
242
276
  ```ruby
243
277
  MyApp::SCHEMA.to_h
@@ -277,8 +311,10 @@ INTEGRATION=1 TARANTOOL_URI=tcp://app:secret@localhost:3302 bundle exec rspec
277
311
 
278
312
  ## Public API
279
313
 
280
- `Tarantool::Schema::Definition` (`new`, `from_live`, `ensure_space`, `ensure_index`, `plan`, `apply`, `to_h`,
281
- `to_ruby`) and the error classes above.
314
+ `Tarantool::Schema::Definition` (`new`, `from_live`, `ensure_space`, `ensure_index`, `grant`, `plan`, `apply`,
315
+ `to_h`, `grants`, `to_ruby`) and the error classes above.
316
+
317
+ RBS signatures for this API ship with the gem in `sig/`.
282
318
 
283
319
  Everything else, including anything tagged `@api private` in its source comment, is an implementation detail
284
320
  and may change in any release.
@@ -4,12 +4,12 @@ module Tarantool
4
4
  module Schema
5
5
  # Differences between one space as declared and as it is live. For a space declared elsewhere (only its
6
6
  # indexes are declared here) there is no spec: engine and format are not compared and index parts inherit
7
- # from the live format.
7
+ # from the live format. Grants are compared only when declared, against what GrantInspector read.
8
8
  # @api private
9
9
  class Comparison
10
- attr_reader :name, :space, :mismatches, :missing_indexes, :format_additions
10
+ attr_reader :name, :space, :mismatches, :missing_indexes, :format_additions, :missing_grants
11
11
 
12
- def initialize(name:, indexes:, live:, space: nil, alter: [])
12
+ def initialize(name:, indexes:, live:, space: nil, alter: [], grants: [], live_grants: nil)
13
13
  @name = name
14
14
  @space = space
15
15
  @live = live
@@ -18,6 +18,7 @@ module Tarantool
18
18
  @missing_indexes = []
19
19
  @format_additions = []
20
20
  live ? compare(indexes) : absent(indexes)
21
+ compare_grants(grants, live_grants)
21
22
  end
22
23
 
23
24
  def missing_space? = @live.nil? && !@space.nil?
@@ -37,6 +38,13 @@ module Tarantool
37
38
  indexes.each { |index| compare_index(index) }
38
39
  end
39
40
 
41
+ def compare_grants(grants, live_grants)
42
+ comparison = GrantComparison.new(grants, live_grants)
43
+ @mismatches.concat(comparison.mismatches)
44
+
45
+ @missing_grants = comparison.missing
46
+ end
47
+
40
48
  def compare_space
41
49
  @mismatches << "engine: declared #{@space.engine}, actual #{@live.engine}" if @space.engine != @live.engine
42
50
  format = FormatComparison.new(@space.format, @live.format, add_field: @alter.include?(:add_field))
@@ -21,6 +21,12 @@ module Tarantool
21
21
  box.space[name]:format(format)
22
22
  LUA
23
23
 
24
+ GRANT_LUA = <<~LUA
25
+ local grantee, privileges, space = ...
26
+ local grant = box.schema.user.exists(grantee) and box.schema.user.grant or box.schema.role.grant
27
+ grant(grantee, privileges, 'space', space, {if_not_exists = true})
28
+ LUA
29
+
24
30
  Space = Data.define(:spec) do
25
31
  def apply(client)
26
32
  client.eval(SPACE_LUA, [spec.name.to_s, spec.to_lua.merge('if_not_exists' => true)])
@@ -45,6 +51,15 @@ module Tarantool
45
51
 
46
52
  def to_s = "index #{space}.#{spec.name}"
47
53
  end
54
+
55
+ # Only the privileges still missing go out; an overlap with what is already granted is not an error.
56
+ Grant = Data.define(:spec) do
57
+ def apply(client)
58
+ client.eval(GRANT_LUA, [spec.user.to_s, spec.privileges.join(','), spec.space.to_s])
59
+ end
60
+
61
+ def to_s = spec.to_s
62
+ end
48
63
  end
49
64
  end
50
65
  end
@@ -2,28 +2,39 @@
2
2
 
3
3
  module Tarantool
4
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.
5
+ # Spaces, indexes and grants an application needs. Built once from a block and frozen, so a definition kept
6
+ # in a constant cannot grow when its file is loaded again. The block runs with the definition as self.
7
7
  class Definition
8
8
  def initialize(&block)
9
9
  @spaces = {}
10
10
  @external = {}
11
+ @grants = {}
11
12
  instance_exec(&block) if block
12
13
  @spaces.transform_values! { |space| SpaceRules.check(space.resolve) }.freeze
13
14
  @external.each_value(&:freeze).freeze
15
+ @grants.each_value(&:freeze).freeze
14
16
  freeze
15
17
  end
16
18
 
17
- # Every user space as the server has it, or only the named ones. A space the DSL cannot express raises.
19
+ # Every user space as the server has it, with the grants on it, or only the named ones. A space the DSL
20
+ # cannot express raises.
18
21
  def self.from_live(client, only: nil)
19
- arguments = SpaceLister.call(client, only:).to_h do |name|
20
- live = Inspector.call(client, name) || raise(ArgumentError, "space #{name}: not found")
22
+ arguments = SpaceLister.call(client, only:).to_h { |name| [name, live_arguments(client, name)] }
21
23
 
22
- [name, LiveDefinition.arguments(name, live)]
24
+ new do
25
+ arguments.each do |name, (space, grants)|
26
+ ensure_space(name, **space)
27
+ grants.each { |user, privileges| grant(user, privileges, on: name) }
28
+ end
23
29
  end
30
+ end
31
+
32
+ def self.live_arguments(client, name)
33
+ live = Inspector.call(client, name) || raise(ArgumentError, "space #{name}: not found")
24
34
 
25
- new { arguments.each { |name, space| ensure_space(name, **space) } }
35
+ [LiveDefinition.arguments(name, live), LiveDefinition.grants(GrantInspector.call(client, name, []))]
26
36
  end
37
+ private_class_method :live_arguments
27
38
 
28
39
  def ensure_space(name, format:, indexes:, engine: :memtx)
29
40
  raise ArgumentError, 'space without name' if name.nil?
@@ -49,9 +60,23 @@ module Tarantool
49
60
  nil
50
61
  end
51
62
 
63
+ # user may be a role too. Privileges already granted are kept, nothing is revoked.
64
+ def grant(user, privileges, on:)
65
+ spec = GrantSpec.build(user, privileges, on:)
66
+ grants = @grants[spec.space] ||= []
67
+ if grants.any? { |declared| declared.user == spec.user }
68
+ raise ArgumentError, "grant #{spec.user} on #{spec.space} is already declared"
69
+ end
70
+
71
+ grants << spec
72
+ nil
73
+ end
74
+
52
75
  def to_h = @spaces.transform_values(&:to_h)
53
76
 
54
- def to_ruby = Source.render(to_h, @external.transform_values { |indexes| indexes.map(&:to_h) })
77
+ def grants = @grants.values.flatten.map(&:to_h)
78
+
79
+ def to_ruby = Source.render(to_h, @external.transform_values { |indexes| indexes.map(&:to_h) }, grants)
55
80
 
56
81
  # alter: lists the changes to live spaces that are allowed besides creating what is missing, see Alter.
57
82
  def plan(client, alter: []) = build_plan(client, Alter.check(alter)).verify.creations.map(&:to_s)
@@ -69,7 +94,7 @@ module Tarantool
69
94
 
70
95
  private
71
96
 
72
- def build_plan(client, alter) = Plan.new(spaces: @spaces, external: @external, client:, alter:)
97
+ def build_plan(client, alter) = Plan.new(spaces: @spaces, external: @external, grants: @grants, client:, alter:)
73
98
 
74
99
  # A space declared elsewhere already has its primary key, so an index added here never gets a sequence.
75
100
  def add_external(space, index)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Declared grants on one space against the live ones: what is still to grant, and grantees that do not
6
+ # exist. Privileges granted but not declared are ignored, nothing is ever revoked. For a space that does not
7
+ # exist yet the live masks are empty, so everything is to grant, and a missing grantee is still a mismatch.
8
+ # @api private
9
+ class GrantComparison
10
+ attr_reader :mismatches, :missing
11
+
12
+ def initialize(grants, live)
13
+ @mismatches = []
14
+ @missing = []
15
+ grants.each { |grant| compare(grant, live) }
16
+ end
17
+
18
+ private
19
+
20
+ def compare(grant, live)
21
+ return @mismatches << "grant #{grant.user}: no such user or role" if live.missing.include?(grant.user.to_s)
22
+
23
+ lacking = grant.missing(live.masks.fetch(grant.user.to_s, 0))
24
+
25
+ @missing << lacking if lacking
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Privilege masks on one space by grantee name, and which of the declared grantees do not exist. A grantee
6
+ # may be a user or a role. Reads _priv and _user, so the connection needs read on both.
7
+ # @api private
8
+ LiveGrants = Data.define(:masks, :missing)
9
+
10
+ # @api private
11
+ module GrantInspector
12
+ LUA = <<~LUA
13
+ local name, grantees = ...
14
+ local masks = setmetatable({}, {__serialize = 'map'})
15
+ local space = box.space[name]
16
+ if space ~= nil then
17
+ for _, row in box.space._priv.index.object:pairs({'space', space.id}) do
18
+ local grantee = box.space._user:get(row[2])
19
+ if grantee ~= nil then masks[grantee.name] = row[5] end
20
+ end
21
+ end
22
+ local missing = setmetatable({}, {__serialize = 'seq'})
23
+ for _, grantee in ipairs(grantees) do
24
+ if not (box.schema.user.exists(grantee) or box.schema.role.exists(grantee)) then
25
+ table.insert(missing, grantee)
26
+ end
27
+ end
28
+ return {masks = masks, missing = missing}
29
+ LUA
30
+
31
+ def self.call(client, space, grantees)
32
+ raw = client.eval(LUA, [space.to_s, grantees.map(&:to_s)]).first
33
+
34
+ LiveGrants.new(masks: raw.fetch('masks'), missing: raw.fetch('missing'))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Privileges one user or role must have on one space.
6
+ # @api private
7
+ GrantSpec = Data.define(:user, :privileges, :space) do
8
+ def self.build(user, privileges, on:)
9
+ raise ArgumentError, 'grant without user' if user.nil?
10
+ raise ArgumentError, "grant #{user} without space" if on.nil?
11
+
12
+ user = user.to_sym
13
+ space = on.to_sym
14
+
15
+ new(user:, privileges: Privileges.check(privileges, "grant #{user} on #{space}").freeze, space:)
16
+ end
17
+
18
+ # The grant that would complete a live mask, or nil when it already covers the declaration.
19
+ def missing(mask)
20
+ lacking = privileges - Privileges.names(mask)
21
+
22
+ lacking.empty? ? nil : with(privileges: lacking.freeze)
23
+ end
24
+
25
+ def to_s = "grant #{user} #{privileges.join(',')} on #{space}"
26
+ end
27
+ end
28
+ end
@@ -15,6 +15,11 @@ module Tarantool
15
15
  indexes: live.indexes.map { |index| index_arguments(name, index, fields) } }
16
16
  end
17
17
 
18
+ # Grants whose privileges the DSL does not know (execute on 2.11) are left out.
19
+ def grants(live)
20
+ live.masks.to_h { |user, mask| [user.to_sym, Privileges.names(mask)] }.reject { |_, names| names.empty? }
21
+ end
22
+
18
23
  def index_arguments(space, index, fields)
19
24
  raise ArgumentError, "space #{space}: index #{index.name} is functional, not supported" if index.func
20
25
 
@@ -5,10 +5,12 @@ module Tarantool
5
5
  # The live state of everything a definition declares, compared with it, and what is left to create.
6
6
  # @api private
7
7
  class Plan
8
- def initialize(spaces:, external:, client:, alter: [])
8
+ def initialize(spaces:, external:, client:, alter: [], grants: {})
9
9
  @alter = alter
10
+ @grants = grants
10
11
  @comparisons = spaces.values.map { |space| compare(client, space.name, space.indexes, space) } +
11
- external.map { |name, indexes| compare(client, name, indexes) }
12
+ external.map { |name, indexes| compare(client, name, indexes) } +
13
+ granted_only(spaces, external).map { |name| compare(client, name, []) }
12
14
  end
13
15
 
14
16
  # complete: anything still missing is a mismatch too, used after apply has created it.
@@ -22,16 +24,20 @@ module Tarantool
22
24
  self
23
25
  end
24
26
 
25
- # Per space: the space itself or its format change, then its indexes, so an index on a new field works.
27
+ # Per space: the space itself or its format change, then its indexes, so an index on a new field works,
28
+ # then its grants.
26
29
  def creations
27
30
  @comparisons.flat_map do |comparison|
28
31
  space_creation(comparison) +
29
- comparison.missing_indexes.map { |index| Creation::Index.new(space: comparison.name, spec: index) }
32
+ comparison.missing_indexes.map { |index| Creation::Index.new(space: comparison.name, spec: index) } +
33
+ comparison.missing_grants.map { |grant| Creation::Grant.new(spec: grant) }
30
34
  end
31
35
  end
32
36
 
33
37
  private
34
38
 
39
+ def granted_only(spaces, external) = @grants.keys - spaces.keys - external.keys
40
+
35
41
  def space_creation(comparison)
36
42
  return [Creation::Space.new(spec: comparison.space)] if comparison.missing_space?
37
43
  return [] if comparison.format_additions.empty?
@@ -40,7 +46,11 @@ module Tarantool
40
46
  end
41
47
 
42
48
  def compare(client, name, indexes, space = nil)
43
- Comparison.new(name:, indexes:, live: Inspector.call(client, name), space:, alter: @alter)
49
+ grants = @grants.fetch(name, [])
50
+ live_grants = GrantInspector.call(client, name, grants.map(&:user)) if grants.any?
51
+
52
+ Comparison.new(name:, indexes:, live: Inspector.call(client, name), space:, alter: @alter, grants:,
53
+ live_grants:)
44
54
  end
45
55
 
46
56
  def still_missing(comparison)
@@ -48,7 +58,8 @@ module Tarantool
48
58
 
49
59
  format = comparison.format_additions.empty? ? [] : ['format: fields missing after apply']
50
60
 
51
- format + comparison.missing_indexes.map { |index| "index #{index.name}: missing after apply" }
61
+ format + comparison.missing_indexes.map { |index| "index #{index.name}: missing after apply" } +
62
+ comparison.missing_grants.map { |grant| "grant #{grant.user}: missing after apply" }
52
63
  end
53
64
  end
54
65
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarantool
4
+ module Schema
5
+ # Privileges a grant on a space may name, with their bits in _priv. execute is left out: 3.x rejects it on
6
+ # a space. Names are kept in bit order, the order box.schema.user.info prints them in.
7
+ # @api private
8
+ module Privileges
9
+ BITS = { 'read' => 1, 'write' => 2, 'usage' => 16, 'create' => 32, 'drop' => 64, 'alter' => 128,
10
+ 'trigger' => 512, 'insert' => 1024, 'update' => 2048, 'delete' => 4096 }.freeze
11
+
12
+ module_function
13
+
14
+ def check(list, context)
15
+ names = Array(list).map(&:to_s)
16
+ raise ArgumentError, "#{context}: no privileges" if names.empty?
17
+
18
+ unknown = names.reject { |name| BITS.key?(name) }
19
+ if unknown.any?
20
+ raise ArgumentError, "#{context}: unknown privilege #{describe(unknown)}; allowed: #{BITS.keys.join(', ')}"
21
+ end
22
+
23
+ BITS.keys & names
24
+ end
25
+
26
+ def describe(names) = names.map { |name| name.to_sym.inspect }.join(', ')
27
+
28
+ def names(mask) = BITS.select { |_, bit| mask.anybits?(bit) }.keys
29
+ end
30
+ end
31
+ end
@@ -5,21 +5,31 @@ module Tarantool
5
5
  # Ruby source of a definition, from its normalized Hash: a Definition.new block with one ensure_space per
6
6
  # space and one ensure_index per index on a space declared elsewhere. Defaults are left out and a part that
7
7
  # inherits everything from its field is a bare name, so the result reads like a hand-written declaration.
8
+ # Grants come last, one line each.
8
9
  # @api private
9
10
  module Source
10
11
  WIDTH = 120
11
12
  ARGUMENT_INDENT = ' ' * 15
12
13
  ENTRY_INDENT = ' ' * 17
14
+ NEEDS_ESCAPING = /['\\]/
13
15
 
14
16
  module_function
15
17
 
16
- def render(spaces, external)
18
+ def render(spaces, external, grants = [])
17
19
  entries = spaces.map { |name, space| space(name, space) }
18
20
  entries += external.map { |name, indexes| indexes(name, indexes) }
21
+ entries << grants.map { |grant| grant(grant) }.join("\n") if grants.any?
19
22
 
20
23
  "Tarantool::Schema::Definition.new do\n#{entries.join("\n\n")}#{"\n" if entries.any?}end\n"
21
24
  end
22
25
 
26
+ def grant(grant)
27
+ privileges = grant[:privileges]
28
+ literal = privileges.one? ? symbol(privileges.first) : "%i[#{privileges.join(' ')}]"
29
+
30
+ " grant #{symbol(grant[:user])}, #{literal}, on: #{symbol(grant[:space])}"
31
+ end
32
+
23
33
  def space(name, space)
24
34
  fields = space[:format].to_h { |field| [field[:name], field] }
25
35
  format = list('format', space[:format].map { |field| field(field) })
@@ -102,7 +112,7 @@ module Tarantool
102
112
 
103
113
  def symbol(name) = name.to_sym.inspect
104
114
 
105
- def string(value) = value.match?(/['\\]/) ? value.inspect : "'#{value}'"
115
+ def string(value) = value.match?(NEEDS_ESCAPING) ? value.inspect : "'#{value}'"
106
116
  end
107
117
  end
108
118
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tarantool
4
4
  module Schema
5
- VERSION = '0.2.1'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -13,6 +13,10 @@ require_relative 'schema/part_spec'
13
13
  require_relative 'schema/index_spec'
14
14
  require_relative 'schema/space_spec'
15
15
  require_relative 'schema/space_rules'
16
+ require_relative 'schema/privileges'
17
+ require_relative 'schema/grant_spec'
18
+ require_relative 'schema/grant_inspector'
19
+ require_relative 'schema/grant_comparison'
16
20
  require_relative 'schema/plan'
17
21
  require_relative 'schema/definition'
18
22
  require_relative 'schema/live_space'
@@ -26,7 +30,7 @@ require_relative 'schema/comparison'
26
30
  require_relative 'schema/creation'
27
31
 
28
32
  module Tarantool
29
- # Idempotent declaration of spaces, indexes and primary key sequences, applied over an admin connection.
33
+ # Idempotent declaration of spaces, indexes, primary key sequences and grants, applied over an admin connection.
30
34
  module Schema
31
35
  end
32
36
  end
@@ -0,0 +1,47 @@
1
+ module Tarantool
2
+ # Idempotent declaration of spaces, indexes and primary key sequences.
3
+ module Schema
4
+ VERSION: String
5
+
6
+ type name = String | Symbol
7
+ # A format entry: { name:, type:, is_nullable: }; type omitted means any, is_nullable defaults to false.
8
+ type field = { name: name, ?type: name, ?is_nullable: bool }
9
+ # A part is a field name, or a Hash with the keys that differ from the field: type, is_nullable, path.
10
+ type part = name | { field: name, ?type: name, ?is_nullable: bool, ?path: String }
11
+ # An index declaration: { name:, parts:, unique: true, type: :tree, sequence: false }.
12
+ type index = { name: name, parts: Array[part], ?unique: bool, ?type: name, ?sequence: bool }
13
+ # Changes to live spaces that plan and apply may make: :add_field.
14
+ type alter = Array[Symbol | String]
15
+
16
+ # Tarantool::Client connected to the read-write instance, or anything else that evaluates Lua.
17
+ interface _Evaluator
18
+ def eval: (String expression, ?Array[untyped] arguments) -> Array[untyped]
19
+ end
20
+
21
+ class Definition
22
+ # The block runs with the definition as self; the definition is frozen afterwards.
23
+ def initialize: () ?{ () [self: Definition] -> void } -> void
24
+ # Every user space as the server has it (ids past the system range, names without a leading underscore),
25
+ # or only the named ones.
26
+ def self.from_live: (_Evaluator client, ?only: Array[name]?) -> Definition
27
+ def ensure_space: (name name, format: Array[field], indexes: Array[index], ?engine: name) -> nil
28
+ def ensure_index: (name space, name name, parts: Array[part], ?unique: bool, ?type: name,
29
+ ?sequence: bool) -> nil
30
+ # The normalized declaration: names as Symbols, types as Strings, every default filled in.
31
+ def to_h: () -> Hash[Symbol, Hash[Symbol, untyped]]
32
+ def to_ruby: () -> String
33
+ def plan: (_Evaluator client, ?alter: alter) -> Array[String]
34
+ def apply: (_Evaluator client, ?alter: alter) -> Array[String]
35
+ end
36
+
37
+ class Error < Tarantool::Error
38
+ end
39
+
40
+ # The live schema differs from the declaration; mismatches are the lines, each prefixed with the space.
41
+ class MismatchError < Error
42
+ attr_reader mismatches: Array[String]
43
+
44
+ def initialize: (Hash[name, Array[String]] by_space) -> void
45
+ end
46
+ end
47
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tarantool-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Starlexxx
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: tarantool-client
@@ -44,6 +44,9 @@ files:
44
44
  - lib/tarantool/schema/errors.rb
45
45
  - lib/tarantool/schema/field_spec.rb
46
46
  - lib/tarantool/schema/format_comparison.rb
47
+ - lib/tarantool/schema/grant_comparison.rb
48
+ - lib/tarantool/schema/grant_inspector.rb
49
+ - lib/tarantool/schema/grant_spec.rb
47
50
  - lib/tarantool/schema/index_comparison.rb
48
51
  - lib/tarantool/schema/index_spec.rb
49
52
  - lib/tarantool/schema/inspector.rb
@@ -53,12 +56,14 @@ files:
53
56
  - lib/tarantool/schema/options.rb
54
57
  - lib/tarantool/schema/part_spec.rb
55
58
  - lib/tarantool/schema/plan.rb
59
+ - lib/tarantool/schema/privileges.rb
56
60
  - lib/tarantool/schema/source.rb
57
61
  - lib/tarantool/schema/space_lister.rb
58
62
  - lib/tarantool/schema/space_rules.rb
59
63
  - lib/tarantool/schema/space_spec.rb
60
64
  - lib/tarantool/schema/types.rb
61
65
  - lib/tarantool/schema/version.rb
66
+ - sig/tarantool/schema.rbs
62
67
  homepage: https://github.com/Starlexxx/tarantool-ruby
63
68
  licenses:
64
69
  - MIT