Skip to main content

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}