ytsaurus_client/path.rs
1//! Table paths that carry attributes.
2//!
3//! A YTsaurus path is not only a string: it is a YSON value, and attributes on
4//! it change what a command does with it. `<append=%true>//tmp/log` and
5//! `//tmp/log` name the same table and mean opposite things — one adds rows,
6//! the other replaces them. `<columns=[host];ranges=[{lower_limit={row_index=0};
7//! upper_limit={row_index=100}}]>//tmp/log` names a hundred rows of one column
8//! of it, which is the difference between a read worth doing over a laptop
9//! link and one that is not.
10//!
11//! This crate sent bare strings until now, so every write replaced the table
12//! and append was unreachable. [`TablePath`] is the type that makes the
13//! attributes expressible, and `From<&str>` is what keeps `client.write_table
14//! ("//tmp/out", …)` reading exactly as it did.
15//!
16//! The attribute spellings are the
17//! [rich YPath reference](https://ytsaurus.tech/docs/en/user-guide/storage/ypath):
18//! `columns` is a list of names, `ranges` a list of maps with `lower_limit`,
19//! `upper_limit` and `exact`, and inside a limit sit `row_index`, `key` and
20//! `key_bound`. The Go SDK's `ypath.Rich` renders the same shapes
21//! (`yt/go/ypath/rich.go`: `Ranges []Range \`yson:"ranges,attr"\``,
22//! `ReadLimit{Key []any; RowIndex *int64}`).
23
24use std::ops::{Bound, RangeBounds};
25
26use ytsaurus_yson::{YsonFormat, YsonNode, YsonValue};
27
28use crate::yson_build;
29
30/// A table to read from or write to, and which part of it.
31///
32/// Built from a `&str` wherever a plain path will do:
33///
34/// ```
35/// # use ytsaurus_client::TablePath;
36/// let replace = TablePath::from("//tmp/log");
37/// let add = TablePath::new("//tmp/log").append();
38/// let head = TablePath::new("//tmp/log").columns(["host", "status"]).range(0..100);
39/// ```
40///
41/// Append is a write-side attribute; columns and ranges are read-side ones,
42/// the same split the C++ `TRichYPath` and the Go `ypath.Rich` carry. The
43/// write methods **refuse** a path with a read selection rather than sending
44/// it — the cluster ignores a selection on a write and replaces the whole
45/// table with a 200, which is silent data loss. Measured on a local cluster,
46/// in both spellings: `write_table_rows("//tmp/t[#0:#2]", rows)` replaced
47/// everything and reported success, and a `write_table` whose path carried
48/// `<ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>` as an
49/// attribute did exactly the same — 200, three rows replaced by one.
50///
51/// The path *string* is never parsed. Rich YPath syntax spelled into it —
52/// `<append=%true>//tmp/t`, `//tmp/t[#0:#2]`, `//tmp/t{a,b}` — goes to the
53/// cluster verbatim on a read, where the cluster honours it, and is refused on
54/// a write, where the cluster would not: the attribute form of a selection is
55/// ignored there, and this type exists so that cannot happen by accident.
56#[derive(Debug, Clone, PartialEq)]
57pub struct TablePath {
58 path: String,
59 append: bool,
60 columns: Option<Vec<String>>,
61 ranges: Vec<RowRange>,
62}
63
64impl TablePath {
65 /// A path that names the whole table: a write **replaces** its contents,
66 /// a read returns every row of every column — the defaults everywhere in
67 /// YTsaurus.
68 #[must_use]
69 pub fn new(path: impl Into<String>) -> Self {
70 Self {
71 path: path.into(),
72 append: false,
73 columns: None,
74 ranges: Vec::new(),
75 }
76 }
77
78 /// Adds rows to the table instead of replacing it.
79 ///
80 /// The table has to exist: appending to a path that does not is refused
81 /// with `Error getting basic attributes of user objects`, which is the
82 /// cluster's way of saying there was nothing to append to.
83 ///
84 /// **A sorted table stays sorted, and the cluster checks.** Rows appended
85 /// after a larger key are refused — `Sort order violation: [0#9] > [0#1]`
86 /// — so an append to a sorted table is a continuation of it rather than an
87 /// addition to it.
88 #[must_use]
89 pub fn append(mut self) -> Self {
90 self.append = true;
91 self
92 }
93
94 /// Reads only the named columns.
95 ///
96 /// Three columns out of a forty-column table cost three columns' worth of
97 /// wire and decode, which is what makes a laptop-side read of a wide table
98 /// reasonable. The names travel as the `columns` attribute on the path,
99 /// which the
100 /// [rich YPath reference](https://ytsaurus.tech/docs/en/user-guide/storage/ypath)
101 /// says is *"recognized by the table data read command (`read_table`)"* —
102 /// and by read commands **only**, which is why the write methods refuse a
103 /// path carrying one rather than letting the cluster ignore it.
104 ///
105 /// **A column the table does not have is not an error** — measured on a
106 /// local cluster: `columns(["a", "nosuch"])` against a table with no
107 /// `nosuch` answered 200, every row carrying only `a`. Rows simply come
108 /// back without the key, exactly as they do for a row with no value in a
109 /// named column, so a typo here reads clean and decodes short. A struct
110 /// decoded from such a read fails loudly on the missing field, which is
111 /// where the typo surfaces; a map decodes to fewer keys and does not.
112 ///
113 /// **The empty selection is that same shape taken to its end, and it is
114 /// sent.** Measured: `<columns=[]>` answers 200 with one empty map per
115 /// row, and it composes with a range —
116 /// `<columns=[];ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>`
117 /// came back as two empty maps, and the same range spelled with `key`
118 /// bounds came back as three. That is how many rows a range holds, or
119 /// whether a key range holds any, with no column bytes on the wire —
120 /// a question [`Client::row_count`](crate::Client::row_count) cannot
121 /// answer, since it reads the `@row_count` attribute and so speaks only
122 /// for a whole static table. It decodes to a map with no keys and to a
123 /// struct missing every field, so name the columns when the *rows* are
124 /// what is wanted.
125 ///
126 /// Calling this again replaces the selection rather than adding to it.
127 #[must_use]
128 pub fn columns(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
129 self.columns = Some(columns.into_iter().map(Into::into).collect());
130 self
131 }
132
133 /// Reads only the rows a [`RowRange`] selects. May be called several
134 /// times: the ranges are read one after another, in the order given —
135 /// the documented meaning of the `ranges` attribute.
136 ///
137 /// Plain Rust ranges convert, so row windows read as they would on a
138 /// slice:
139 ///
140 /// ```
141 /// # use ytsaurus_client::{Key, RowRange, TablePath};
142 /// let first_two = TablePath::new("//tmp/t").range(0..2);
143 /// let from_key = TablePath::new("//tmp/t")
144 /// .range(RowRange::keys(Key::from("alice")..Key::from("bob")));
145 /// ```
146 #[must_use]
147 pub fn range(mut self, range: impl Into<RowRange>) -> Self {
148 self.ranges.push(range.into());
149 self
150 }
151
152 /// Whether [`TablePath::append`] was called on this path.
153 ///
154 /// Not "whether the cluster will append": the cluster parses attributes out
155 /// of the path *string* too, so a path built from the text
156 /// `<append=%true>//tmp/t` appends while this answers `false`. Spelling the
157 /// attribute into the string is not a supported way to ask for it, and a
158 /// *write* to such a string is refused outright — see [`TablePath`].
159 #[must_use]
160 pub fn is_append(&self) -> bool {
161 self.append
162 }
163
164 /// The columns [`TablePath::columns`] selected, if any.
165 #[must_use]
166 pub fn selected_columns(&self) -> Option<&[String]> {
167 self.columns.as_deref()
168 }
169
170 /// The ranges [`TablePath::range`] added, in the order they will be read.
171 #[must_use]
172 pub fn selected_ranges(&self) -> &[RowRange] {
173 &self.ranges
174 }
175
176 /// The path itself, without the attributes.
177 #[must_use]
178 pub fn as_str(&self) -> &str {
179 &self.path
180 }
181
182 /// The path as the command parameter wants it.
183 ///
184 /// A bare string when there is nothing to say, because that is what every
185 /// version of this crate has sent and there is no reason for the common
186 /// case to start looking different on the wire.
187 pub(crate) fn to_yson(&self) -> YsonValue {
188 let path = yson_build::string(&self.path);
189 let mut attributes: Vec<(&str, YsonValue)> = Vec::new();
190 if self.append {
191 attributes.push(("append", yson_build::boolean(true)));
192 }
193 if let Some(columns) = &self.columns {
194 attributes.push((
195 "columns",
196 yson_build::list(columns.iter().map(yson_build::string)),
197 ));
198 }
199 if !self.ranges.is_empty() {
200 attributes.push((
201 "ranges",
202 yson_build::list(self.ranges.iter().map(RowRange::to_yson)),
203 ));
204 }
205 if attributes.is_empty() {
206 path
207 } else {
208 yson_build::with_attributes(path, attributes)
209 }
210 }
211
212 /// Why a write must not send this path, if it must not.
213 ///
214 /// Two families of refusal, both protecting against the same measured
215 /// failure — a selection on a write is **ignored with a 200** and the
216 /// whole table is replaced:
217 ///
218 /// - a typed selection ([`TablePath::columns`] / [`TablePath::range`]),
219 /// which only read commands recognise;
220 /// - rich YPath syntax spelled into the path string — a leading `<…>`
221 /// attribute block, or an unescaped `[` / `{` — which this client never
222 /// parses and a write-side cluster silently strips.
223 pub(crate) fn write_refusal(&self) -> Option<String> {
224 if self.columns.is_some() {
225 return Some(format!(
226 "{self}: a write cannot select columns — the cluster ignores the \
227 `columns` attribute on a write and writes whole rows, reporting \
228 success; column selection belongs on reads"
229 ));
230 }
231 if !self.ranges.is_empty() {
232 return Some(format!(
233 "{self}: a write cannot take a row range — the cluster ignores the \
234 `ranges` attribute on a write and replaces the whole table with a \
235 200, which is silent data loss; ranges belong on reads"
236 ));
237 }
238 if self.path.starts_with('<') {
239 return Some(format!(
240 "{}: this client does not parse attributes out of a path string, and \
241 that one syntax hides two opposite outcomes on a write — the cluster \
242 honours `<append=%true>` there, as it did before this type existed, \
243 and silently ignores `<ranges=…>` or `<columns=…>` while replacing \
244 the whole table with a 200. Refusing is the only answer that is right \
245 for both: use TablePath::append() to append, or Client::raw_command \
246 for any other write attribute",
247 self.path
248 ));
249 }
250 if let Some(selector) = first_unescaped_selector(&self.path) {
251 return Some(format!(
252 "{}: `{selector}` in the path string is rich YPath selection syntax, \
253 which the cluster silently ignores on a write — \
254 write_table(\"//tmp/t[#0:#2]\", …) replaced the whole table and \
255 answered 200 — so a write takes a bare path; select rows and \
256 columns on reads, with TablePath::range and TablePath::columns \
257 (a literal `[` or `{{` in a node name is escaped as `\\[` / `\\{{`)",
258 self.path
259 ));
260 }
261 None
262 }
263
264 /// Why a read must not send this path, if it must not.
265 ///
266 /// A read passes the string through verbatim, selection syntax and all —
267 /// the cluster honours it there, and code that read `//tmp/t[#0:#2]`
268 /// before this type existed keeps working. Two shapes are refused: a range
269 /// asking for rows no table has, and a typed selection landing on a string
270 /// that already spells **the same kind** of selection, where the typed one
271 /// silently wins and the caller's string half is discarded — see
272 /// [`TablePath::selection_conflict`] for the measurements and for why the
273 /// other pairings go through.
274 pub(crate) fn read_refusal(&self) -> Option<String> {
275 for range in &self.ranges {
276 if let Some(reason) = range.refusal() {
277 return Some(format!("{}: {reason}", self.path));
278 }
279 }
280 self.selection_conflict(
281 self.columns.is_some(),
282 !self.ranges.is_empty(),
283 "TablePath::columns",
284 "TablePath::range",
285 )
286 }
287
288 /// Why the attributes this client is about to add cannot ride on this
289 /// path string, if they cannot.
290 ///
291 /// The client never parses a path string. It sends the string as a YSON
292 /// string node and hangs its own attributes *outside* it —
293 /// `<columns=[n]>"//tmp/t{k}"`, not the flat text
294 /// `<columns=[n]>//tmp/t{k}` — and the cluster reads both halves. Measured
295 /// on a local cluster, in that wire shape, every combination answers 200
296 /// and one rule covers all of them: **the outer attribute wins, and the
297 /// selector spelled in the string is silently discarded.**
298 ///
299 /// - **The same kind spelled twice: the caller's string half is dropped
300 /// without a word.** `<columns=[n]>"//tmp/t{k}"` answered with column
301 /// `n` — the `{k}` the caller wrote had no effect;
302 /// `<ranges=[…0:2]>"//tmp/t[#3:#5]"` answered with rows 0–1, not 3–4.
303 /// Inside a leading block it is the same:
304 /// `<columns=[k]>"<columns=[n]>//tmp/t"` answered with column `k`.
305 /// Nothing is corrupted — the read is exactly what the *attribute*
306 /// asked for — but the caller is told nothing about the half that was
307 /// thrown away, which is the whole trap this type exists to close.
308 /// - **Different kinds compose, so they are allowed.** Rows and columns
309 /// answer different questions: `<columns=[n]>"//tmp/t[#3:#5]"` gave rows
310 /// 3–4 carrying only `n`, and `<ranges=[…0:2]>"//tmp/t{k}"` gave rows
311 /// 0–1 carrying only `k`. Both are the read that was asked for.
312 /// - **A leading `<…>` is honoured, and is refused only because this
313 /// client cannot read it.** With nothing added, `"<columns=[n]>//tmp/t"`
314 /// answered with column `n`, so the string's own block works; adding a
315 /// *different* kind composes, as `<ranges=[…0:2]>"<columns=[n]>//tmp/t"`
316 /// (rows 0–1, column `n`) showed. But telling those apart means parsing
317 /// the block to see which attribute it names, which this client does
318 /// not do — and if it names the one being added, the caller's is
319 /// discarded silently. Refusing the whole shape is the conservative
320 /// answer to a block that cannot be read; there is no cluster error
321 /// here to point at.
322 ///
323 /// `adding_columns` / `adding_rows` say which attributes are going on;
324 /// `columns_source` / `rows_source` name what is putting them there, since
325 /// a Skiff read **synthesises** `columns` from its format's fields whether
326 /// the caller named columns or not.
327 pub(crate) fn selection_conflict(
328 &self,
329 adding_columns: bool,
330 adding_rows: bool,
331 columns_source: &str,
332 rows_source: &str,
333 ) -> Option<String> {
334 if !adding_columns && !adding_rows {
335 return None;
336 }
337 if self.path.starts_with('<') {
338 return Some(format!(
339 "{}: the path string opens with an attribute block, and this client \
340 does not parse it, so it cannot tell whether that block names the \
341 same attribute this command is about to add. If it does, the added \
342 one wins and the block's is discarded silently, at 200 — measured, \
343 `<columns=[k]>\"<columns=[n]>//tmp/t\"` read column `k` and said \
344 nothing about `n`. Give the command a bare path and say the \
345 attributes once, with TablePath",
346 self.path
347 ));
348 }
349 // Both selectors can appear on one string — `//tmp/t{a}[#0:#2]` is the
350 // documented spelling — so each is asked about separately rather than
351 // through whichever came first.
352 let spelled = unescaped_selectors(&self.path);
353 if adding_columns && spelled.columns {
354 return Some(format!(
355 "{}: the path string already selects columns with `{{…}}`, and this \
356 client does not parse it; the `columns` attribute {columns_source} \
357 adds would be the second column selection on one path, and the \
358 added attribute wins — measured, `<columns=[n]>\"//tmp/t{{k}}\"` read \
359 column `n` and discarded the `{{k}}` without a word, at 200. Say the \
360 column selection once",
361 self.path
362 ));
363 }
364 if adding_rows && spelled.rows {
365 return Some(format!(
366 "{}: the path string already selects rows with `[…]`, and this client \
367 does not parse it; the `ranges` attribute {rows_source} adds would be \
368 the second row selection on one path, and the added attribute wins — \
369 measured, `<ranges=[…0:2]>\"//tmp/t[#3:#5]\"` read rows 0-1 and \
370 discarded the `[#3:#5]` without a word, at 200. Say the row \
371 selection once",
372 self.path
373 ));
374 }
375 None
376 }
377}
378
379/// The first unescaped `[` or `{` in a path string, if any.
380///
381/// Rich YPath escapes a literal bracket in a node name as `\[` / `\{`
382/// (and a literal backslash as `\\`), so an unescaped one is selection
383/// syntax, not a name.
384fn first_unescaped_selector(path: &str) -> Option<char> {
385 let mut bytes = path.bytes();
386 while let Some(byte) = bytes.next() {
387 match byte {
388 b'\\' => {
389 bytes.next();
390 }
391 b'[' => return Some('['),
392 b'{' => return Some('{'),
393 _ => {}
394 }
395 }
396 None
397}
398
399/// Which selections a path string spells, by the same escaping rule.
400///
401/// Separate from [`first_unescaped_selector`] because one string can carry
402/// both — `//tmp/t{host}[#0:#2]` selects columns *and* rows — and the two are
403/// answered differently: only the kind the client is about to add a second
404/// time is a conflict.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406struct Selectors {
407 /// An unescaped `[`: a row range.
408 rows: bool,
409 /// An unescaped `{`: a column selection.
410 columns: bool,
411}
412
413fn unescaped_selectors(path: &str) -> Selectors {
414 let mut found = Selectors {
415 rows: false,
416 columns: false,
417 };
418 let mut bytes = path.bytes();
419 while let Some(byte) = bytes.next() {
420 match byte {
421 b'\\' => {
422 bytes.next();
423 }
424 b'[' => found.rows = true,
425 b'{' => found.columns = true,
426 _ => {}
427 }
428 }
429 found
430}
431
432/// One entry of a path's `ranges` attribute: which rows to read.
433///
434/// Built three ways, one per selector the
435/// [rich YPath reference](https://ytsaurus.tech/docs/en/user-guide/storage/ypath)
436/// defines:
437///
438/// - [`RowRange::rows`] — by row index; plain Rust ranges convert via `Into`,
439/// so `path.range(0..100)` reads as it would on a slice;
440/// - [`RowRange::keys`] — by key, on a sorted table;
441/// - [`RowRange::exact_key`] — exactly the rows whose key starts with a tuple.
442///
443/// A range never mixes `exact` with a lower or upper limit, because no
444/// constructor can express that — the reference defines them as alternatives.
445#[derive(Debug, Clone, PartialEq)]
446pub struct RowRange {
447 lower: Option<Limit>,
448 upper: Option<Limit>,
449 exact: Option<Limit>,
450}
451
452impl RowRange {
453 /// Rows by index: `rows(0..100)`, `rows(100..)`, `rows(..)`.
454 ///
455 /// Rust range semantics and the cluster's are the same — `lower_limit` is
456 /// inclusive and `upper_limit` exclusive for a `row_index` (the reference:
457 /// *"All limit types except for `key_bound` are inclusive in the
458 /// `lower_limit` attribute and exclusive in the `upper_limit`
459 /// attribute"*) — so `0..2` means rows 0 and 1 on both sides of the wire,
460 /// and `..=2` rows 0 through 2.
461 #[must_use]
462 pub fn rows(rows: impl RangeBounds<i64>) -> Self {
463 // The two saturations at i64's edge are exact, not approximate: a
464 // table's row count fits in an i64, so no row has index i64::MAX —
465 // an exclusive lower bound *at* i64::MAX excludes every possible row
466 // either way, and an inclusive upper bound there includes them all.
467 let lower = match rows.start_bound() {
468 Bound::Included(&index) => Some(Limit::RowIndex(index)),
469 Bound::Excluded(&index) => Some(Limit::RowIndex(index.saturating_add(1))),
470 Bound::Unbounded => None,
471 };
472 let upper = match rows.end_bound() {
473 Bound::Included(&index) => index.checked_add(1).map(Limit::RowIndex),
474 Bound::Excluded(&index) => Some(Limit::RowIndex(index)),
475 Bound::Unbounded => None,
476 };
477 Self {
478 lower,
479 upper,
480 exact: None,
481 }
482 }
483
484 /// Rows by key, on a sorted table.
485 ///
486 /// Takes a Rust range of [`Key`]s, and the inclusivity travels with it:
487 ///
488 /// - `keys(a..b)` — from `a` inclusive to `b` exclusive. These are the
489 /// spellings the `key` selector has natively (inclusive in
490 /// `lower_limit`, exclusive in `upper_limit`), so they are sent as
491 /// `{key=[…]}`, exactly what the Go SDK's `ypath.Key` sends.
492 /// - `keys(a..=b)` — inclusive upper bound. The `key` selector cannot say
493 /// that, so it is sent as the cluster's `key_bound` form,
494 /// `{key_bound=["<="; […]]}`; an exclusive *lower* bound
495 /// (`(Bound::Excluded(a), …)`) likewise becomes `{key_bound=[">"; […]]}`.
496 /// The reference defines `key_bound` as `[relation; prefix]` with `>`
497 /// `>=` allowed only in `lower_limit` and `<` `<=` only in
498 /// `upper_limit`, and this constructor is what makes the wrong pairing
499 /// unwritable.
500 ///
501 /// **A key shorter than the table's key columns is a prefix bound — and
502 /// the two selectors compare a prefix by opposite rules.**
503 ///
504 /// `key` compares the row's whole key against the bound component-wise,
505 /// the shorter tuple being smaller when equal so far. `key_bound` does
506 /// not: the reference says the row's key is first **truncated** to the
507 /// bound's length — *"we need to extract a prefix of length K from that
508 /// key and perform a lexicographic comparison"* — after which every row
509 /// sharing the prefix compares *equal* to the bound. So `<=` takes that
510 /// whole group and `>` drops that whole group, and the practical
511 /// consequence is that `a..b` and `a..=b` differ by a group of rows
512 /// rather than by one row.
513 ///
514 /// Measured on a local cluster, on a table keyed `(host, path)` holding
515 /// `(a,/x) (a,/y) (b,/x) (b,/y) (c,/x)`:
516 ///
517 /// | asked for | sent | rows back |
518 /// | --- | --- | --- |
519 /// | `keys(a..b)` | `{key=[a]}` … `{key=[b]}` | `(a,/x) (a,/y)` |
520 /// | `keys(a..=b)` | `{key=[a]}` … `{key_bound=["<=";[b]]}` | `(a,/x) (a,/y) (b,/x) (b,/y)` |
521 /// | `keys((Excluded(a), Unbounded))` | `{key_bound=[">";[a]]}` | `(b,/x) (b,/y) (c,/x)` |
522 /// | `keys(a..=a)` | `{key=[a]}` … `{key_bound=["<=";[a]]}` | `(a,/x) (a,/y)` |
523 ///
524 /// The third row is the one to remember: an exclusive lower bound on a
525 /// *prefix* excludes every row of that prefix, not the one row equal to
526 /// it — there is no "the row just after `a`" for the cluster to start
527 /// from. Give a full key if you want a single row skipped.
528 ///
529 /// The second row settles the other question a mixed range raises: an
530 /// entry carrying `key` on one side and `key_bound` on the other is
531 /// **accepted** — the same local cluster answered it 200 with the rows
532 /// above — so the most natural inclusive spelling needs no workaround.
533 #[must_use]
534 pub fn keys(keys: impl RangeBounds<Key>) -> Self {
535 let lower = match keys.start_bound() {
536 Bound::Included(key) => Some(Limit::Key(key.clone())),
537 Bound::Excluded(key) => Some(Limit::KeyBound {
538 relation: ">",
539 key: key.clone(),
540 }),
541 Bound::Unbounded => None,
542 };
543 let upper = match keys.end_bound() {
544 Bound::Included(key) => Some(Limit::KeyBound {
545 relation: "<=",
546 key: key.clone(),
547 }),
548 Bound::Excluded(key) => Some(Limit::Key(key.clone())),
549 Bound::Unbounded => None,
550 };
551 Self {
552 lower,
553 upper,
554 exact: None,
555 }
556 }
557
558 /// Exactly the rows whose full key starts with `key`.
559 ///
560 /// The `exact` selector of the reference: *"only returns those rows where
561 /// the full key contains the `key` tuple as its prefix"*. On a table keyed
562 /// by `(host, path)`, `exact_key(Key::from("example.com"))` is every row
563 /// of that host — the same rows `keys(k..=k)` selects, measured on a
564 /// local cluster against the table in [`RowRange::keys`], said in the
565 /// cluster's own word for it.
566 #[must_use]
567 pub fn exact_key(key: impl Into<Key>) -> Self {
568 Self {
569 lower: None,
570 upper: None,
571 exact: Some(Limit::Key(key.into())),
572 }
573 }
574
575 /// The range as one entry of the `ranges` attribute.
576 pub(crate) fn to_yson(&self) -> YsonValue {
577 let mut entries: Vec<(&str, YsonValue)> = Vec::new();
578 if let Some(exact) = &self.exact {
579 entries.push(("exact", exact.to_yson()));
580 }
581 if let Some(lower) = &self.lower {
582 entries.push(("lower_limit", lower.to_yson()));
583 }
584 if let Some(upper) = &self.upper {
585 entries.push(("upper_limit", upper.to_yson()));
586 }
587 yson_build::map(entries)
588 }
589
590 /// Why this range asks for rows no table has, if it does.
591 ///
592 /// The cluster validates neither shape, and the two go wrong differently —
593 /// measured on a local cluster against a five-row table:
594 ///
595 /// - **A negative `row_index` is clamped to 0 and the read succeeds**, so
596 /// the bound is not so much rejected as quietly replaced.
597 /// `{lower_limit={row_index=-5}}` returned **all five rows**, and
598 /// `{lower_limit={row_index=-5};upper_limit={row_index=2}}` returned rows
599 /// 0 and 1 — a lower limit of `-5` reads exactly as `0` would. A negative
600 /// *upper* limit clamps the same way and therefore selects nothing:
601 /// `{upper_limit={row_index=-2}}` came back 200 and empty. So a
602 /// miscomputed offset either reads from the start of the table or reads
603 /// nothing at all, and both are reported as success.
604 /// - **A backwards range is answered 200 with no rows.**
605 /// `{lower_limit={row_index=5};upper_limit={row_index=3}}` returned
606 /// nothing, and so did the key spelling,
607 /// `{lower_limit={key=[3]};upper_limit={key=[1]}}`.
608 ///
609 /// Rust refuses the same mistake on a slice (`&rows[5..3]` panics with
610 /// *"slice index starts at 5 but ends at 3"*) and clippy will not even
611 /// compile the literal, so a range built from Rust's own syntax refuses it
612 /// here rather than spending a round trip. Neither shape can be written
613 /// deliberately: both arrive *computed*, from a page number or an offset
614 /// that came out wrong, and reading the whole table under a bound that
615 /// asked for something else is worse than an error, not better. An
616 /// *empty* range is fine: `5..5` is legal on a slice and asks honestly for
617 /// no rows, and `keys(a..a)` likewise.
618 fn refusal(&self) -> Option<String> {
619 for limit in [&self.lower, &self.upper] {
620 if let Some(Limit::RowIndex(index)) = limit
621 && *index < 0
622 {
623 return Some(format!(
624 "row index {index} is negative, and rows are numbered from 0 — the \
625 cluster clamps it to 0 and answers 200, so a negative lower limit \
626 reads from the start of the table as if it had said 0 and a \
627 negative upper limit selects nothing; either way a bound that was \
628 never honoured is reported as success"
629 ));
630 }
631 }
632 if let (Some(Limit::RowIndex(lower)), Some(Limit::RowIndex(upper))) =
633 (&self.lower, &self.upper)
634 && lower > upper
635 {
636 return Some(format!(
637 "the row range starts at {lower} and ends at {upper}, as \
638 `&rows[{lower}..{upper}]` would; the cluster answers it with 200 and \
639 no rows rather than with an error"
640 ));
641 }
642 if let (Some(lower), Some(upper)) = (self.lower.as_ref(), self.upper.as_ref())
643 && let (Some(lower), Some(upper)) = (lower.key(), upper.key())
644 && key_ordering(lower, upper) == Some(std::cmp::Ordering::Greater)
645 {
646 return Some(
647 "the key range starts after it ends, the same mistake as \
648 `&rows[5..3]`; the cluster answers it with 200 and no rows rather \
649 than with an error"
650 .to_owned(),
651 );
652 }
653 None
654 }
655}
656
657/// How two key tuples order, when this client can tell.
658///
659/// Component-wise, which is how the cluster compares them, with a shorter
660/// tuple sorting first when the components it has all match — the `key` rule
661/// [`RowRange::keys`] documents. Two components of different YSON types are
662/// **not** compared: the cluster's own answer there is not the obvious one
663/// (measured, it reads an int64 `42` and a uint64 `42u` as the same key), so
664/// a mixed pair returns `None` and no refusal follows. This only ever has to
665/// be right about ranges it refuses.
666fn key_ordering(lower: &Key, upper: &Key) -> Option<std::cmp::Ordering> {
667 for (lower, upper) in lower.0.iter().zip(upper.0.iter()) {
668 if lower.attributes.is_some() || upper.attributes.is_some() {
669 return None;
670 }
671 let ordering = match (&lower.node, &upper.node) {
672 (YsonNode::Boolean(lower), YsonNode::Boolean(upper)) => lower.cmp(upper),
673 (YsonNode::Int64(lower), YsonNode::Int64(upper)) => lower.cmp(upper),
674 (YsonNode::Uint64(lower), YsonNode::Uint64(upper)) => lower.cmp(upper),
675 (YsonNode::String(lower), YsonNode::String(upper)) => lower.cmp(upper),
676 (YsonNode::Double(lower), YsonNode::Double(upper)) => lower.partial_cmp(upper)?,
677 _ => return None,
678 };
679 if ordering != std::cmp::Ordering::Equal {
680 return Some(ordering);
681 }
682 }
683 Some(lower.0.len().cmp(&upper.0.len()))
684}
685
686impl From<std::ops::Range<i64>> for RowRange {
687 fn from(rows: std::ops::Range<i64>) -> Self {
688 Self::rows(rows)
689 }
690}
691
692impl From<std::ops::RangeFrom<i64>> for RowRange {
693 fn from(rows: std::ops::RangeFrom<i64>) -> Self {
694 Self::rows(rows)
695 }
696}
697
698impl From<std::ops::RangeTo<i64>> for RowRange {
699 fn from(rows: std::ops::RangeTo<i64>) -> Self {
700 Self::rows(rows)
701 }
702}
703
704impl From<std::ops::RangeInclusive<i64>> for RowRange {
705 fn from(rows: std::ops::RangeInclusive<i64>) -> Self {
706 Self::rows(rows)
707 }
708}
709
710impl From<std::ops::RangeToInclusive<i64>> for RowRange {
711 fn from(rows: std::ops::RangeToInclusive<i64>) -> Self {
712 Self::rows(rows)
713 }
714}
715
716impl From<std::ops::RangeFull> for RowRange {
717 fn from(rows: std::ops::RangeFull) -> Self {
718 Self::rows(rows)
719 }
720}
721
722/// One limit of a [`RowRange`], in the cluster's own representation.
723#[derive(Debug, Clone, PartialEq)]
724enum Limit {
725 /// `{row_index=N}`.
726 RowIndex(i64),
727 /// `{key=[…]}` — inclusive as a lower limit, exclusive as an upper one.
728 Key(Key),
729 /// `{key_bound=[relation; […]]}` — the two inclusivities `key` cannot say.
730 KeyBound { relation: &'static str, key: Key },
731}
732
733impl Limit {
734 /// The key this limit compares against, whichever selector spells it.
735 ///
736 /// `keys(a..=b)` puts a `key` on one side and a `key_bound` on the other,
737 /// so a backwards range has to be recognised across both spellings.
738 fn key(&self) -> Option<&Key> {
739 match self {
740 Limit::RowIndex(_) => None,
741 Limit::Key(key) | Limit::KeyBound { key, .. } => Some(key),
742 }
743 }
744
745 fn to_yson(&self) -> YsonValue {
746 match self {
747 Limit::RowIndex(index) => yson_build::map([("row_index", yson_build::int(*index))]),
748 Limit::Key(key) => yson_build::map([("key", key.to_yson())]),
749 Limit::KeyBound { relation, key } => yson_build::map([(
750 "key_bound",
751 yson_build::list([yson_build::string(relation), key.to_yson()]),
752 )]),
753 }
754 }
755}
756
757/// A key tuple: the value of one row's key columns, or a prefix of them.
758///
759/// A key is a **list** of YSON values, compared component-wise — the same
760/// `[]any` the Go SDK's `ypath.Key(values …any)` takes. Single-component keys
761/// convert from the value itself; a composite or mixed-type key is spelled
762/// with [`yson_build`](crate::yson_build):
763///
764/// ```
765/// # use ytsaurus_client::{Key, yson_build};
766/// let host = Key::from("example.com");
767/// let host_and_code = Key::new([yson_build::string("example.com"), yson_build::int(404)]);
768/// let visit = Key::new([yson_build::uint(1_700_000_000)]);
769/// ```
770///
771/// The `From` shortcuts cover the types a key column usually has. `From<i64>`
772/// sends an **int64**, and on a `uint64` key column that is not a mismatch —
773/// measured on a `uint64`-keyed table, `{exact={key=[42]}}` and
774/// `{exact={key=[42u]}}` both returned the same row, so the cluster reads the
775/// two as one key. What `i64` cannot do is *reach* the top of that column:
776/// every key above `i64::MAX` is unnameable by it, and only
777/// [`yson_build::uint`] gets there —
778/// `{exact={key=[18446744073709551615u]}}` returned its row. That ceiling is
779/// why the helper exists.
780#[derive(Debug, Clone, PartialEq)]
781pub struct Key(Vec<YsonValue>);
782
783impl Key {
784 /// A key from its component values, in key-column order.
785 #[must_use]
786 pub fn new(parts: impl IntoIterator<Item = YsonValue>) -> Self {
787 Self(parts.into_iter().collect())
788 }
789
790 fn to_yson(&self) -> YsonValue {
791 yson_build::list(self.0.iter().cloned())
792 }
793}
794
795impl From<&str> for Key {
796 fn from(part: &str) -> Self {
797 Self(vec![yson_build::string(part)])
798 }
799}
800
801impl From<String> for Key {
802 fn from(part: String) -> Self {
803 Self(vec![yson_build::string(part)])
804 }
805}
806
807impl From<i64> for Key {
808 fn from(part: i64) -> Self {
809 Self(vec![yson_build::int(part)])
810 }
811}
812
813impl From<Vec<YsonValue>> for Key {
814 fn from(parts: Vec<YsonValue>) -> Self {
815 Self(parts)
816 }
817}
818
819impl From<&str> for TablePath {
820 fn from(path: &str) -> Self {
821 Self::new(path)
822 }
823}
824
825impl From<String> for TablePath {
826 fn from(path: String) -> Self {
827 Self::new(path)
828 }
829}
830
831impl From<&String> for TablePath {
832 fn from(path: &String) -> Self {
833 Self::new(path.as_str())
834 }
835}
836
837impl From<&TablePath> for TablePath {
838 fn from(path: &TablePath) -> Self {
839 path.clone()
840 }
841}
842
843// The shapes `&str` used to absorb by deref coercion and `Into` does not. A
844// `&&str` is what `for path in &paths` hands you, and a `Cow<str>` is what a
845// function that sometimes rewrites a path returns; neither is exotic, and
846// leaving them out would break code that compiled before this type existed.
847impl From<&&str> for TablePath {
848 fn from(path: &&str) -> Self {
849 Self::new(*path)
850 }
851}
852
853impl From<std::borrow::Cow<'_, str>> for TablePath {
854 fn from(path: std::borrow::Cow<'_, str>) -> Self {
855 Self::new(path.into_owned())
856 }
857}
858
859impl std::fmt::Display for TablePath {
860 /// Prints the path the way the cluster spells it —
861 /// `<append=%true;columns=[a]>//tmp/out` — so an error naming the path
862 /// says which rows and columns were in play, not only which table.
863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864 let value = self.to_yson();
865 if let Some(attributes) = &value.attributes {
866 f.write_str("<")?;
867 for (i, (name, attribute)) in attributes.iter().enumerate() {
868 if i > 0 {
869 f.write_str(";")?;
870 }
871 // Encoding a value this type built cannot fail — every leaf is
872 // a string, an int or a boolean — so the fallback is belt and
873 // braces rather than a reachable path.
874 let rendered = ytsaurus_yson::to_string(attribute, YsonFormat::Text)
875 .unwrap_or_else(|_| "?".to_owned());
876 write!(f, "{}={rendered}", String::from_utf8_lossy(name))?;
877 }
878 f.write_str(">")?;
879 }
880 f.write_str(&self.path)
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use ytsaurus_yson::to_string;
888
889 fn rendered(path: &TablePath) -> String {
890 to_string(&path.to_yson(), YsonFormat::Text).expect("encodes")
891 }
892
893 #[test]
894 fn a_plain_path_is_a_plain_string() {
895 // What every version of this crate has sent. A path that started
896 // carrying `<append=%false>` would be a change in the request for no
897 // change in the meaning.
898 assert_eq!(rendered(&TablePath::from("//tmp/out")), r#""//tmp/out""#);
899 }
900
901 #[test]
902 fn an_appending_path_carries_the_attribute() {
903 assert_eq!(
904 rendered(&TablePath::new("//tmp/out").append()),
905 r#"<append=%true>"//tmp/out""#
906 );
907 }
908
909 #[test]
910 fn a_column_selection_is_a_list_on_the_path() {
911 // The doc's spelling: `columns` is an attribute on the path holding a
912 // list of names. A sibling parameter would be ignored, exactly as a
913 // sibling `append` is.
914 assert_eq!(
915 rendered(&TablePath::new("//tmp/t").columns(["host", "status"])),
916 r#"<columns=[host;status]>"//tmp/t""#
917 );
918 }
919
920 #[test]
921 fn naming_columns_again_replaces_the_selection() {
922 // Two calls are one decision revised, not a union — a caller looping
923 // over candidate selections must get the last one, not the sum.
924 assert_eq!(
925 rendered(&TablePath::new("//tmp/t").columns(["a"]).columns(["b"])),
926 r#"<columns=[b]>"//tmp/t""#
927 );
928 }
929
930 #[test]
931 fn a_row_range_renders_the_documented_limits() {
932 // `0..2` is rows 0 and 1 in Rust and on the cluster: lower_limit
933 // inclusive, upper_limit exclusive, both spelled row_index.
934 assert_eq!(
935 rendered(&TablePath::new("//tmp/t").range(0..2)),
936 r#"<ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>"//tmp/t""#
937 );
938 }
939
940 #[test]
941 fn half_open_row_ranges_leave_the_absent_limit_out() {
942 assert_eq!(
943 rendered(&TablePath::new("//tmp/t").range(10..)),
944 r#"<ranges=[{lower_limit={row_index=10}}]>"//tmp/t""#
945 );
946 assert_eq!(
947 rendered(&TablePath::new("//tmp/t").range(..5)),
948 r#"<ranges=[{upper_limit={row_index=5}}]>"//tmp/t""#
949 );
950 // `..` is a range with nothing to say, and an empty map is how the
951 // ranges list says "everything".
952 assert_eq!(
953 rendered(&TablePath::new("//tmp/t").range(..)),
954 r#"<ranges=[{}]>"//tmp/t""#
955 );
956 }
957
958 #[test]
959 fn inclusive_row_bounds_become_the_exclusive_wire_form() {
960 // `..=2` includes row 2; the wire only has an exclusive upper
961 // row_index, so it travels as 3.
962 assert_eq!(
963 rendered(&TablePath::new("//tmp/t").range(0..=2)),
964 r#"<ranges=[{lower_limit={row_index=0};upper_limit={row_index=3}}]>"//tmp/t""#
965 );
966 // An exclusive lower bound has no wire form either; it travels as the
967 // next index.
968 assert_eq!(
969 rendered(
970 &TablePath::new("//tmp/t")
971 .range(RowRange::rows((Bound::Excluded(4_i64), Bound::Unbounded)))
972 ),
973 r#"<ranges=[{lower_limit={row_index=5}}]>"//tmp/t""#
974 );
975 }
976
977 #[test]
978 fn an_inclusive_bound_at_the_top_of_i64_means_unbounded() {
979 // `..=i64::MAX` has no exclusive spelling one greater, and needs
980 // none: no row index exceeds i64::MAX, so the honest translation is
981 // "no upper limit" — not a saturated bound that would silently drop
982 // the last representable row.
983 assert_eq!(
984 rendered(&TablePath::new("//tmp/t").range(0..=i64::MAX)),
985 r#"<ranges=[{lower_limit={row_index=0}}]>"//tmp/t""#
986 );
987 }
988
989 #[test]
990 fn key_ranges_use_the_key_selector_where_it_says_the_right_thing() {
991 // The `key` selector is inclusive below and exclusive above by
992 // definition, which is exactly what a Rust `a..b` means — so those two
993 // bounds are sent in the doc's plain form, the one the Go SDK sends.
994 assert_eq!(
995 rendered(
996 &TablePath::new("//tmp/t")
997 .range(RowRange::keys(Key::from("alice")..Key::from("bob")))
998 ),
999 r#"<ranges=[{lower_limit={key=[alice]};upper_limit={key=[bob]}}]>"//tmp/t""#
1000 );
1001 }
1002
1003 #[test]
1004 fn the_other_two_inclusivities_use_key_bound() {
1005 // `key` cannot say "strictly above" or "up to and including"; the
1006 // documented `key_bound` form — `[relation; prefix]` — can, and the
1007 // relation the constructor picks is the only one the docs allow on
1008 // that side (`>` `>=` below, `<` `<=` above).
1009 assert_eq!(
1010 rendered(&TablePath::new("//tmp/t").range(RowRange::keys((
1011 Bound::Excluded(Key::from("alice")),
1012 Bound::Included(Key::from("bob"))
1013 )))),
1014 r#"<ranges=[{lower_limit={key_bound=[">";[alice]]};upper_limit={key_bound=["<=";[bob]]}}]>"//tmp/t""#
1015 );
1016 // `a..=b` is the common way to ask for an inclusive top.
1017 assert_eq!(
1018 rendered(
1019 &TablePath::new("//tmp/t")
1020 .range(RowRange::keys(Key::from("alice")..=Key::from("bob")))
1021 ),
1022 r#"<ranges=[{lower_limit={key=[alice]};upper_limit={key_bound=["<=";[bob]]}}]>"//tmp/t""#
1023 );
1024 }
1025
1026 #[test]
1027 fn an_exact_key_is_the_exact_selector() {
1028 assert_eq!(
1029 rendered(&TablePath::new("//tmp/t").range(RowRange::exact_key(Key::from("alice")))),
1030 r#"<ranges=[{exact={key=[alice]}}]>"//tmp/t""#
1031 );
1032 }
1033
1034 #[test]
1035 fn a_composite_key_keeps_its_components_in_order() {
1036 // A key is a tuple compared component-wise; the order given is the
1037 // order sent, because reordering it would compare different columns.
1038 //
1039 // `example.com` renders unquoted — the text writer's identifier rule
1040 // (`ser::is_safe_unquoted`) allows `.` in the tail. Both spellings are
1041 // the same YSON string, and this literal is *fixed*, so pinning the
1042 // rendering is safe where pinning a generated value's would not be.
1043 let key = Key::new([yson_build::string("example.com"), yson_build::int(404)]);
1044 assert_eq!(
1045 rendered(&TablePath::new("//tmp/t").range(RowRange::exact_key(key))),
1046 r#"<ranges=[{exact={key=[example.com;404]}}]>"//tmp/t""#
1047 );
1048 }
1049
1050 #[test]
1051 fn several_ranges_are_read_in_the_order_given() {
1052 // "The specified ranges will be read sequentially, in the order in
1053 // which they are specified" — so the Vec must not be sorted or
1054 // deduplicated on the way out.
1055 assert_eq!(
1056 rendered(&TablePath::new("//tmp/t").range(5..6).range(0..1)),
1057 r#"<ranges=[{lower_limit={row_index=5};upper_limit={row_index=6}};{lower_limit={row_index=0};upper_limit={row_index=1}}]>"//tmp/t""#
1058 );
1059 }
1060
1061 #[test]
1062 fn everything_a_path_can_say_fits_on_one_path() {
1063 // Append beside a read selection renders fine — the write methods are
1064 // what refuse the combination, not the renderer, because a read of an
1065 // append-marked path is harmless and refusing it here would make
1066 // TablePath order-sensitive.
1067 assert_eq!(
1068 rendered(
1069 &TablePath::new("//tmp/t")
1070 .append()
1071 .columns(["a"])
1072 .range(0..1)
1073 ),
1074 r#"<append=%true;columns=[a];ranges=[{lower_limit={row_index=0};upper_limit={row_index=1}}]>"//tmp/t""#
1075 );
1076 }
1077
1078 #[test]
1079 fn it_is_built_from_every_shape_of_string_a_call_site_has() {
1080 // Deref coercion used to absorb all of these when the parameter was a
1081 // `&str`, and `Into` does not: each one that is missing is a call site
1082 // that stops compiling when this type arrives. `&&str` is what
1083 // `for path in &paths` gives you, and `Cow` is what a function that
1084 // sometimes rewrites a path returns.
1085 let owned = String::from("//tmp/out");
1086 let borrowed: &str = "//tmp/out";
1087 let paths = vec!["//tmp/out"];
1088
1089 assert_eq!(TablePath::from("//tmp/out").as_str(), "//tmp/out");
1090 assert_eq!(TablePath::from(owned.clone()).as_str(), "//tmp/out");
1091 assert_eq!(TablePath::from(&owned).as_str(), "//tmp/out");
1092 assert_eq!(TablePath::from(&borrowed).as_str(), "//tmp/out");
1093 assert_eq!(
1094 TablePath::from(std::borrow::Cow::Borrowed("//tmp/out")).as_str(),
1095 "//tmp/out"
1096 );
1097 for path in &paths {
1098 assert_eq!(TablePath::from(path).as_str(), "//tmp/out");
1099 }
1100 }
1101
1102 #[test]
1103 fn it_prints_the_way_the_cluster_spells_it() {
1104 // So that an error message naming the path says which of the two it
1105 // was. "wrote to //tmp/out" and "appended to //tmp/out" are different
1106 // events and the difference is the whole feature.
1107 assert_eq!(TablePath::from("//tmp/out").to_string(), "//tmp/out");
1108 assert_eq!(
1109 TablePath::new("//tmp/out").append().to_string(),
1110 "<append=%true>//tmp/out"
1111 );
1112 // A selection prints too: an error about a partial read should say
1113 // which part was being read.
1114 assert_eq!(
1115 TablePath::new("//tmp/t")
1116 .columns(["a"])
1117 .range(0..2)
1118 .to_string(),
1119 "<columns=[a];ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>//tmp/t"
1120 );
1121 }
1122
1123 #[test]
1124 fn append_is_a_property_of_the_path_and_not_of_the_string() {
1125 let path = TablePath::new("//tmp/out");
1126 assert!(!path.is_append());
1127 assert!(path.clone().append().is_append());
1128 // The original is unchanged: the builder returns a new value, so a path
1129 // held for reuse cannot be turned into an appending one behind the
1130 // caller's back.
1131 assert!(!path.is_append());
1132 }
1133
1134 #[test]
1135 fn a_write_refuses_a_typed_read_selection() {
1136 // The design driver of this module: on a write the cluster ignores
1137 // `columns` and `ranges` and replaces the whole table with a 200.
1138 // Sending them anyway would be that silent loss with nicer syntax.
1139 //
1140 // The assertions name the sentence each branch alone can produce. A
1141 // looser `contains("range")` would be satisfied by the `Display`
1142 // prefix this message opens with — `<ranges=…>//tmp/t` has "range" in
1143 // it — and so could not tell the two branches apart at all.
1144 let columns = TablePath::new("//tmp/t").columns(["a"]);
1145 let reason = columns.write_refusal().expect("refused");
1146 assert!(reason.contains("a write cannot select columns"), "{reason}");
1147 assert!(
1148 !reason.contains("a write cannot take a row range"),
1149 "{reason}"
1150 );
1151
1152 let ranged = TablePath::new("//tmp/t").range(0..2);
1153 let reason = ranged.write_refusal().expect("refused");
1154 assert!(
1155 reason.contains("a write cannot take a row range"),
1156 "{reason}"
1157 );
1158 assert!(
1159 !reason.contains("a write cannot select columns"),
1160 "{reason}"
1161 );
1162
1163 assert!(TablePath::new("//tmp/t").write_refusal().is_none());
1164 assert!(TablePath::new("//tmp/t").append().write_refusal().is_none());
1165 }
1166
1167 #[test]
1168 fn a_range_asking_for_rows_no_table_has_is_refused() {
1169 // Written from variables because clippy's `reversed_empty_ranges`
1170 // will not compile the literal `5..3` — which is the shape a real
1171 // caller hits too: a backwards range only ever arrives computed, from
1172 // a page number or an offset that came out wrong. Measured, the
1173 // cluster answers it 200 with no rows.
1174 let (from, to) = (5_i64, 3_i64);
1175 let backwards = TablePath::new("//tmp/t").range(from..to);
1176 let reason = backwards.read_refusal().expect("refused");
1177 assert!(reason.contains("starts at 5 and ends at 3"), "{reason}");
1178
1179 // A negative row index is the *other* failure, and not an empty read:
1180 // measured, the cluster clamps it to 0 and answers 200, so
1181 // `{lower_limit={row_index=-5}}` returned all five rows of a five-row
1182 // table and `-5..2` returned rows 0 and 1. The bound is silently
1183 // replaced rather than refused, which is what makes it worth catching
1184 // here. The assertion names the clamp so the pre-fix wording — "200
1185 // and no rows" — could not satisfy it.
1186 for range in [-5..2, -5..0] {
1187 let negative = TablePath::new("//tmp/t").range(range);
1188 let reason = negative.read_refusal().expect("refused");
1189 assert!(reason.contains("is negative"), "{reason}");
1190 assert!(reason.contains("clamps it to 0"), "{reason}");
1191 assert!(
1192 reason.contains("reads from the start of the table"),
1193 "{reason}"
1194 );
1195 }
1196 // The upper limit is checked too, where the clamp empties the read.
1197 let negative_upper = TablePath::new("//tmp/t").range(..-2);
1198 assert!(
1199 negative_upper
1200 .read_refusal()
1201 .expect("refused")
1202 .contains("is negative")
1203 );
1204
1205 // A backwards *key* range is the same mistake in the other selector,
1206 // and the cluster answers it the same way — measured,
1207 // `{lower_limit={key=[3]};upper_limit={key=[1]}}` came back 200 with
1208 // no rows, exactly as `rows(5..3)` did. Refusing one and sending the
1209 // other would be an inconsistency with nothing behind it.
1210 let reason = TablePath::new("//tmp/t")
1211 .range(RowRange::keys(Key::from("b")..Key::from("a")))
1212 .read_refusal()
1213 .expect("refused");
1214 assert!(reason.contains("starts after it ends"), "{reason}");
1215 // Including across the two spellings one range entry can mix: an
1216 // inclusive key range puts `key` on the low side and `key_bound` on
1217 // the high side, and backwards is still backwards.
1218 assert!(
1219 TablePath::new("//tmp/t")
1220 .range(RowRange::keys(Key::from("b")..=Key::from("a")))
1221 .read_refusal()
1222 .is_some()
1223 );
1224
1225 // An *empty* range is not a broken one: `&rows[5..5]` is legal and
1226 // means no rows, so a caller computing `start..end` that came out
1227 // equal gets an honest empty read rather than an error. The same for
1228 // keys, and for a forwards range of either kind.
1229 for path in [
1230 TablePath::new("//tmp/t").range(5..5),
1231 TablePath::new("//tmp/t").range(0..1),
1232 TablePath::new("//tmp/t").range(RowRange::keys(Key::from("a")..Key::from("a"))),
1233 TablePath::new("//tmp/t").range(RowRange::keys(Key::from("a")..Key::from("b"))),
1234 // A prefix sorts before the longer key it starts, which is the
1235 // `key` rule, so this is forwards and stays sendable.
1236 TablePath::new("//tmp/t").range(RowRange::keys(
1237 Key::from("a")..Key::new([yson_build::string("a"), yson_build::int(1)]),
1238 )),
1239 // Mixed component types are not compared at all: the cluster
1240 // reads int64 42 and uint64 42u as one key, so this client does
1241 // not claim to know which of two types sorts first.
1242 TablePath::new("//tmp/t").range(RowRange::keys(
1243 Key::new([yson_build::uint(9)])..Key::new([yson_build::int(2)]),
1244 )),
1245 ] {
1246 assert!(path.read_refusal().is_none(), "{path} was refused");
1247 }
1248 }
1249
1250 #[test]
1251 fn an_empty_column_selection_is_sent() {
1252 // Measured on a local cluster: `<columns=[]>` answers 200 with one
1253 // empty map per row, and it composes with a range —
1254 // `<columns=[];ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>`
1255 // came back as two empty maps. That is a row count over a *range*
1256 // with no column bytes on the wire, which `Client::row_count` cannot
1257 // give — it reads `@row_count`, a whole-table attribute. A read that
1258 // returns one correct record per row is not a request that cannot
1259 // succeed, so it goes.
1260 let empty = TablePath::new("//tmp/t").columns(Vec::<String>::new());
1261 assert!(empty.read_refusal().is_none());
1262 assert_eq!(
1263 ytsaurus_yson::to_string(&empty.to_yson(), YsonFormat::Text).unwrap(),
1264 r#"<columns=[]>"//tmp/t""#
1265 );
1266 let counted = TablePath::new("//tmp/t")
1267 .columns(Vec::<String>::new())
1268 .range(0..2);
1269 assert!(counted.read_refusal().is_none());
1270 }
1271
1272 #[test]
1273 fn a_write_refuses_selection_syntax_spelled_into_the_string() {
1274 // The measured trap, verbatim from the local cluster:
1275 // write_table_rows("//tmp/t[#0:#2]", rows) replaced the whole table
1276 // and returned success. The string is not parsed; it is refused.
1277 for path in ["//tmp/t[#0:#2]", "//tmp/t{a,b}", "<append=%true>//tmp/t"] {
1278 let refusal = TablePath::new(path).write_refusal();
1279 assert!(refusal.is_some(), "{path} was not refused");
1280 }
1281 // An escaped bracket is a node name, not syntax, and stays writable.
1282 assert!(TablePath::new(r"//tmp/t\[x\]").write_refusal().is_none());
1283 assert!(TablePath::new(r"//tmp/t\{x\}").write_refusal().is_none());
1284 }
1285
1286 #[test]
1287 fn a_read_takes_the_string_verbatim_unless_the_same_selection_joins_it() {
1288 // Reads honoured string-spelled ranges before this type existed, and
1289 // still do — the cluster reads them correctly there, and a bare
1290 // string is passed through whatever it spells.
1291 for path in [
1292 "//tmp/t[#0:#2]",
1293 "//tmp/t{a}",
1294 "<columns=[a]>//tmp/t",
1295 "//tmp/t{a}[#0:#2]",
1296 ] {
1297 assert!(
1298 TablePath::new(path).read_refusal().is_none(),
1299 "bare {path} was refused"
1300 );
1301 }
1302 assert!(
1303 TablePath::new("//tmp/t")
1304 .columns(["a"])
1305 .read_refusal()
1306 .is_none()
1307 );
1308
1309 // The same *kind* of selection spelled twice is the shape with no
1310 // right answer, and measured it is not a draw: in the wire shape this
1311 // client sends — attributes hung outside a YSON string node — the
1312 // added attribute wins and the caller's string half is discarded
1313 // without a word, at 200. `<ranges=[…0:2]>"//tmp/t[#3:#5]"` returned
1314 // rows 0-1, and `<columns=[n]>"//tmp/t{k}"` returned column `n`.
1315 let reason = TablePath::new("//tmp/t[#0:#2]")
1316 .range(0..2)
1317 .read_refusal()
1318 .expect("refused");
1319 assert!(reason.contains("already selects rows"), "{reason}");
1320 let reason = TablePath::new("//tmp/t{a}")
1321 .columns(["b"])
1322 .read_refusal()
1323 .expect("refused");
1324 assert!(reason.contains("already selects columns"), "{reason}");
1325
1326 // Different kinds compose, and are sent. Measured:
1327 // `<columns=[n]>"//tmp/t[#3:#5]"` gave rows 3-4 carrying only `n`, and
1328 // `<ranges=[…0:2]>"//tmp/t{k}"` gave rows 0-1 carrying only `k`. Both
1329 // are the read that was asked for, so refusing them would take a
1330 // working capability away for a conflict that is not there.
1331 assert!(
1332 TablePath::new("//tmp/t[#0:#2]")
1333 .columns(["a"])
1334 .read_refusal()
1335 .is_none()
1336 );
1337 assert!(
1338 TablePath::new("//tmp/t{a}")
1339 .range(0..2)
1340 .read_refusal()
1341 .is_none()
1342 );
1343 // Unless the string spells both, in which case the doubled half still
1344 // bites — `first_unescaped_selector` would only have seen the `{`.
1345 assert!(
1346 TablePath::new("//tmp/t{a}[#0:#2]")
1347 .range(0..2)
1348 .read_refusal()
1349 .is_some()
1350 );
1351
1352 // A leading attribute block is refused whatever it holds and whatever
1353 // is being added — not because the cluster objects (it does not:
1354 // `<ranges=[…0:2]>"<columns=[n]>//tmp/t"` answered 200 with rows 0-1
1355 // carrying only `n`, the two composing like any other different
1356 // kinds) but because this client cannot read the block to know which
1357 // attribute it names. If it names the one being added, the added one
1358 // wins silently — `<columns=[k]>"<columns=[n]>//tmp/t"` read `k`. The
1359 // conservative answer is the only one available without parsing.
1360 for path in ["<columns=[a]>//tmp/t", "<primary_medium=default>//tmp/t"] {
1361 let reason = TablePath::new(path)
1362 .range(0..2)
1363 .read_refusal()
1364 .expect("refused");
1365 assert!(reason.contains("cannot tell whether"), "{reason}");
1366 assert!(reason.contains("discarded silently"), "{reason}");
1367 }
1368 }
1369}