use std::ops::{Bound, RangeBounds};
use ytsaurus_yson::{YsonFormat, YsonNode, YsonValue};
use crate::yson_build;
#[derive(Debug, Clone, PartialEq)]
pub struct TablePath {
path: String,
append: bool,
columns: Option<Vec<String>>,
ranges: Vec<RowRange>,
}
impl TablePath {
#[must_use]
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
append: false,
columns: None,
ranges: Vec::new(),
}
}
#[must_use]
pub fn append(mut self) -> Self {
self.append = true;
self
}
#[must_use]
pub fn columns(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.columns = Some(columns.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn range(mut self, range: impl Into<RowRange>) -> Self {
self.ranges.push(range.into());
self
}
#[must_use]
pub fn is_append(&self) -> bool {
self.append
}
#[must_use]
pub fn selected_columns(&self) -> Option<&[String]> {
self.columns.as_deref()
}
#[must_use]
pub fn selected_ranges(&self) -> &[RowRange] {
&self.ranges
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.path
}
pub(crate) fn to_yson(&self) -> YsonValue {
let path = yson_build::string(&self.path);
let mut attributes: Vec<(&str, YsonValue)> = Vec::new();
if self.append {
attributes.push(("append", yson_build::boolean(true)));
}
if let Some(columns) = &self.columns {
attributes.push((
"columns",
yson_build::list(columns.iter().map(yson_build::string)),
));
}
if !self.ranges.is_empty() {
attributes.push((
"ranges",
yson_build::list(self.ranges.iter().map(RowRange::to_yson)),
));
}
if attributes.is_empty() {
path
} else {
yson_build::with_attributes(path, attributes)
}
}
pub(crate) fn write_refusal(&self) -> Option<String> {
if self.columns.is_some() {
return Some(format!(
"{self}: a write cannot select columns — the cluster ignores the \
`columns` attribute on a write and writes whole rows, reporting \
success; column selection belongs on reads"
));
}
if !self.ranges.is_empty() {
return Some(format!(
"{self}: a write cannot take a row range — the cluster ignores the \
`ranges` attribute on a write and replaces the whole table with a \
200, which is silent data loss; ranges belong on reads"
));
}
if self.path.starts_with('<') {
return Some(format!(
"{}: this client does not parse attributes out of a path string, and \
that one syntax hides two opposite outcomes on a write — the cluster \
honours `<append=%true>` there, as it did before this type existed, \
and silently ignores `<ranges=…>` or `<columns=…>` while replacing \
the whole table with a 200. Refusing is the only answer that is right \
for both: use TablePath::append() to append, or Client::raw_command \
for any other write attribute",
self.path
));
}
if let Some(selector) = first_unescaped_selector(&self.path) {
return Some(format!(
"{}: `{selector}` in the path string is rich YPath selection syntax, \
which the cluster silently ignores on a write — \
write_table(\"//tmp/t[#0:#2]\", …) replaced the whole table and \
answered 200 — so a write takes a bare path; select rows and \
columns on reads, with TablePath::range and TablePath::columns \
(a literal `[` or `{{` in a node name is escaped as `\\[` / `\\{{`)",
self.path
));
}
None
}
pub(crate) fn read_refusal(&self) -> Option<String> {
for range in &self.ranges {
if let Some(reason) = range.refusal() {
return Some(format!("{}: {reason}", self.path));
}
}
self.selection_conflict(
self.columns.is_some(),
!self.ranges.is_empty(),
"TablePath::columns",
"TablePath::range",
)
}
pub(crate) fn selection_conflict(
&self,
adding_columns: bool,
adding_rows: bool,
columns_source: &str,
rows_source: &str,
) -> Option<String> {
if !adding_columns && !adding_rows {
return None;
}
if self.path.starts_with('<') {
return Some(format!(
"{}: the path string opens with an attribute block, and this client \
does not parse it, so it cannot tell whether that block names the \
same attribute this command is about to add. If it does, the added \
one wins and the block's is discarded silently, at 200 — measured, \
`<columns=[k]>\"<columns=[n]>//tmp/t\"` read column `k` and said \
nothing about `n`. Give the command a bare path and say the \
attributes once, with TablePath",
self.path
));
}
let spelled = unescaped_selectors(&self.path);
if adding_columns && spelled.columns {
return Some(format!(
"{}: the path string already selects columns with `{{…}}`, and this \
client does not parse it; the `columns` attribute {columns_source} \
adds would be the second column selection on one path, and the \
added attribute wins — measured, `<columns=[n]>\"//tmp/t{{k}}\"` read \
column `n` and discarded the `{{k}}` without a word, at 200. Say the \
column selection once",
self.path
));
}
if adding_rows && spelled.rows {
return Some(format!(
"{}: the path string already selects rows with `[…]`, and this client \
does not parse it; the `ranges` attribute {rows_source} adds would be \
the second row selection on one path, and the added attribute wins — \
measured, `<ranges=[…0:2]>\"//tmp/t[#3:#5]\"` read rows 0-1 and \
discarded the `[#3:#5]` without a word, at 200. Say the row \
selection once",
self.path
));
}
None
}
}
fn first_unescaped_selector(path: &str) -> Option<char> {
let mut bytes = path.bytes();
while let Some(byte) = bytes.next() {
match byte {
b'\\' => {
bytes.next();
}
b'[' => return Some('['),
b'{' => return Some('{'),
_ => {}
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Selectors {
rows: bool,
columns: bool,
}
fn unescaped_selectors(path: &str) -> Selectors {
let mut found = Selectors {
rows: false,
columns: false,
};
let mut bytes = path.bytes();
while let Some(byte) = bytes.next() {
match byte {
b'\\' => {
bytes.next();
}
b'[' => found.rows = true,
b'{' => found.columns = true,
_ => {}
}
}
found
}
#[derive(Debug, Clone, PartialEq)]
pub struct RowRange {
lower: Option<Limit>,
upper: Option<Limit>,
exact: Option<Limit>,
}
impl RowRange {
#[must_use]
pub fn rows(rows: impl RangeBounds<i64>) -> Self {
let lower = match rows.start_bound() {
Bound::Included(&index) => Some(Limit::RowIndex(index)),
Bound::Excluded(&index) => Some(Limit::RowIndex(index.saturating_add(1))),
Bound::Unbounded => None,
};
let upper = match rows.end_bound() {
Bound::Included(&index) => index.checked_add(1).map(Limit::RowIndex),
Bound::Excluded(&index) => Some(Limit::RowIndex(index)),
Bound::Unbounded => None,
};
Self {
lower,
upper,
exact: None,
}
}
#[must_use]
pub fn keys(keys: impl RangeBounds<Key>) -> Self {
let lower = match keys.start_bound() {
Bound::Included(key) => Some(Limit::Key(key.clone())),
Bound::Excluded(key) => Some(Limit::KeyBound {
relation: ">",
key: key.clone(),
}),
Bound::Unbounded => None,
};
let upper = match keys.end_bound() {
Bound::Included(key) => Some(Limit::KeyBound {
relation: "<=",
key: key.clone(),
}),
Bound::Excluded(key) => Some(Limit::Key(key.clone())),
Bound::Unbounded => None,
};
Self {
lower,
upper,
exact: None,
}
}
#[must_use]
pub fn exact_key(key: impl Into<Key>) -> Self {
Self {
lower: None,
upper: None,
exact: Some(Limit::Key(key.into())),
}
}
pub(crate) fn to_yson(&self) -> YsonValue {
let mut entries: Vec<(&str, YsonValue)> = Vec::new();
if let Some(exact) = &self.exact {
entries.push(("exact", exact.to_yson()));
}
if let Some(lower) = &self.lower {
entries.push(("lower_limit", lower.to_yson()));
}
if let Some(upper) = &self.upper {
entries.push(("upper_limit", upper.to_yson()));
}
yson_build::map(entries)
}
fn refusal(&self) -> Option<String> {
for limit in [&self.lower, &self.upper] {
if let Some(Limit::RowIndex(index)) = limit
&& *index < 0
{
return Some(format!(
"row index {index} is negative, and rows are numbered from 0 — the \
cluster clamps it to 0 and answers 200, so a negative lower limit \
reads from the start of the table as if it had said 0 and a \
negative upper limit selects nothing; either way a bound that was \
never honoured is reported as success"
));
}
}
if let (Some(Limit::RowIndex(lower)), Some(Limit::RowIndex(upper))) =
(&self.lower, &self.upper)
&& lower > upper
{
return Some(format!(
"the row range starts at {lower} and ends at {upper}, as \
`&rows[{lower}..{upper}]` would; the cluster answers it with 200 and \
no rows rather than with an error"
));
}
if let (Some(lower), Some(upper)) = (self.lower.as_ref(), self.upper.as_ref())
&& let (Some(lower), Some(upper)) = (lower.key(), upper.key())
&& key_ordering(lower, upper) == Some(std::cmp::Ordering::Greater)
{
return Some(
"the key range starts after it ends, the same mistake as \
`&rows[5..3]`; the cluster answers it with 200 and no rows rather \
than with an error"
.to_owned(),
);
}
None
}
}
fn key_ordering(lower: &Key, upper: &Key) -> Option<std::cmp::Ordering> {
for (lower, upper) in lower.0.iter().zip(upper.0.iter()) {
if lower.attributes.is_some() || upper.attributes.is_some() {
return None;
}
let ordering = match (&lower.node, &upper.node) {
(YsonNode::Boolean(lower), YsonNode::Boolean(upper)) => lower.cmp(upper),
(YsonNode::Int64(lower), YsonNode::Int64(upper)) => lower.cmp(upper),
(YsonNode::Uint64(lower), YsonNode::Uint64(upper)) => lower.cmp(upper),
(YsonNode::String(lower), YsonNode::String(upper)) => lower.cmp(upper),
(YsonNode::Double(lower), YsonNode::Double(upper)) => lower.partial_cmp(upper)?,
_ => return None,
};
if ordering != std::cmp::Ordering::Equal {
return Some(ordering);
}
}
Some(lower.0.len().cmp(&upper.0.len()))
}
impl From<std::ops::Range<i64>> for RowRange {
fn from(rows: std::ops::Range<i64>) -> Self {
Self::rows(rows)
}
}
impl From<std::ops::RangeFrom<i64>> for RowRange {
fn from(rows: std::ops::RangeFrom<i64>) -> Self {
Self::rows(rows)
}
}
impl From<std::ops::RangeTo<i64>> for RowRange {
fn from(rows: std::ops::RangeTo<i64>) -> Self {
Self::rows(rows)
}
}
impl From<std::ops::RangeInclusive<i64>> for RowRange {
fn from(rows: std::ops::RangeInclusive<i64>) -> Self {
Self::rows(rows)
}
}
impl From<std::ops::RangeToInclusive<i64>> for RowRange {
fn from(rows: std::ops::RangeToInclusive<i64>) -> Self {
Self::rows(rows)
}
}
impl From<std::ops::RangeFull> for RowRange {
fn from(rows: std::ops::RangeFull) -> Self {
Self::rows(rows)
}
}
#[derive(Debug, Clone, PartialEq)]
enum Limit {
RowIndex(i64),
Key(Key),
KeyBound { relation: &'static str, key: Key },
}
impl Limit {
fn key(&self) -> Option<&Key> {
match self {
Limit::RowIndex(_) => None,
Limit::Key(key) | Limit::KeyBound { key, .. } => Some(key),
}
}
fn to_yson(&self) -> YsonValue {
match self {
Limit::RowIndex(index) => yson_build::map([("row_index", yson_build::int(*index))]),
Limit::Key(key) => yson_build::map([("key", key.to_yson())]),
Limit::KeyBound { relation, key } => yson_build::map([(
"key_bound",
yson_build::list([yson_build::string(relation), key.to_yson()]),
)]),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Key(Vec<YsonValue>);
impl Key {
#[must_use]
pub fn new(parts: impl IntoIterator<Item = YsonValue>) -> Self {
Self(parts.into_iter().collect())
}
fn to_yson(&self) -> YsonValue {
yson_build::list(self.0.iter().cloned())
}
}
impl From<&str> for Key {
fn from(part: &str) -> Self {
Self(vec![yson_build::string(part)])
}
}
impl From<String> for Key {
fn from(part: String) -> Self {
Self(vec![yson_build::string(part)])
}
}
impl From<i64> for Key {
fn from(part: i64) -> Self {
Self(vec![yson_build::int(part)])
}
}
impl From<Vec<YsonValue>> for Key {
fn from(parts: Vec<YsonValue>) -> Self {
Self(parts)
}
}
impl From<&str> for TablePath {
fn from(path: &str) -> Self {
Self::new(path)
}
}
impl From<String> for TablePath {
fn from(path: String) -> Self {
Self::new(path)
}
}
impl From<&String> for TablePath {
fn from(path: &String) -> Self {
Self::new(path.as_str())
}
}
impl From<&TablePath> for TablePath {
fn from(path: &TablePath) -> Self {
path.clone()
}
}
impl From<&&str> for TablePath {
fn from(path: &&str) -> Self {
Self::new(*path)
}
}
impl From<std::borrow::Cow<'_, str>> for TablePath {
fn from(path: std::borrow::Cow<'_, str>) -> Self {
Self::new(path.into_owned())
}
}
impl std::fmt::Display for TablePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let value = self.to_yson();
if let Some(attributes) = &value.attributes {
f.write_str("<")?;
for (i, (name, attribute)) in attributes.iter().enumerate() {
if i > 0 {
f.write_str(";")?;
}
let rendered = ytsaurus_yson::to_string(attribute, YsonFormat::Text)
.unwrap_or_else(|_| "?".to_owned());
write!(f, "{}={rendered}", String::from_utf8_lossy(name))?;
}
f.write_str(">")?;
}
f.write_str(&self.path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ytsaurus_yson::to_string;
fn rendered(path: &TablePath) -> String {
to_string(&path.to_yson(), YsonFormat::Text).expect("encodes")
}
#[test]
fn a_plain_path_is_a_plain_string() {
assert_eq!(rendered(&TablePath::from("//tmp/out")), r#""//tmp/out""#);
}
#[test]
fn an_appending_path_carries_the_attribute() {
assert_eq!(
rendered(&TablePath::new("//tmp/out").append()),
r#"<append=%true>"//tmp/out""#
);
}
#[test]
fn a_column_selection_is_a_list_on_the_path() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").columns(["host", "status"])),
r#"<columns=[host;status]>"//tmp/t""#
);
}
#[test]
fn naming_columns_again_replaces_the_selection() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").columns(["a"]).columns(["b"])),
r#"<columns=[b]>"//tmp/t""#
);
}
#[test]
fn a_row_range_renders_the_documented_limits() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(0..2)),
r#"<ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>"//tmp/t""#
);
}
#[test]
fn half_open_row_ranges_leave_the_absent_limit_out() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(10..)),
r#"<ranges=[{lower_limit={row_index=10}}]>"//tmp/t""#
);
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(..5)),
r#"<ranges=[{upper_limit={row_index=5}}]>"//tmp/t""#
);
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(..)),
r#"<ranges=[{}]>"//tmp/t""#
);
}
#[test]
fn inclusive_row_bounds_become_the_exclusive_wire_form() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(0..=2)),
r#"<ranges=[{lower_limit={row_index=0};upper_limit={row_index=3}}]>"//tmp/t""#
);
assert_eq!(
rendered(
&TablePath::new("//tmp/t")
.range(RowRange::rows((Bound::Excluded(4_i64), Bound::Unbounded)))
),
r#"<ranges=[{lower_limit={row_index=5}}]>"//tmp/t""#
);
}
#[test]
fn an_inclusive_bound_at_the_top_of_i64_means_unbounded() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(0..=i64::MAX)),
r#"<ranges=[{lower_limit={row_index=0}}]>"//tmp/t""#
);
}
#[test]
fn key_ranges_use_the_key_selector_where_it_says_the_right_thing() {
assert_eq!(
rendered(
&TablePath::new("//tmp/t")
.range(RowRange::keys(Key::from("alice")..Key::from("bob")))
),
r#"<ranges=[{lower_limit={key=[alice]};upper_limit={key=[bob]}}]>"//tmp/t""#
);
}
#[test]
fn the_other_two_inclusivities_use_key_bound() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(RowRange::keys((
Bound::Excluded(Key::from("alice")),
Bound::Included(Key::from("bob"))
)))),
r#"<ranges=[{lower_limit={key_bound=[">";[alice]]};upper_limit={key_bound=["<=";[bob]]}}]>"//tmp/t""#
);
assert_eq!(
rendered(
&TablePath::new("//tmp/t")
.range(RowRange::keys(Key::from("alice")..=Key::from("bob")))
),
r#"<ranges=[{lower_limit={key=[alice]};upper_limit={key_bound=["<=";[bob]]}}]>"//tmp/t""#
);
}
#[test]
fn an_exact_key_is_the_exact_selector() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(RowRange::exact_key(Key::from("alice")))),
r#"<ranges=[{exact={key=[alice]}}]>"//tmp/t""#
);
}
#[test]
fn a_composite_key_keeps_its_components_in_order() {
let key = Key::new([yson_build::string("example.com"), yson_build::int(404)]);
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(RowRange::exact_key(key))),
r#"<ranges=[{exact={key=[example.com;404]}}]>"//tmp/t""#
);
}
#[test]
fn several_ranges_are_read_in_the_order_given() {
assert_eq!(
rendered(&TablePath::new("//tmp/t").range(5..6).range(0..1)),
r#"<ranges=[{lower_limit={row_index=5};upper_limit={row_index=6}};{lower_limit={row_index=0};upper_limit={row_index=1}}]>"//tmp/t""#
);
}
#[test]
fn everything_a_path_can_say_fits_on_one_path() {
assert_eq!(
rendered(
&TablePath::new("//tmp/t")
.append()
.columns(["a"])
.range(0..1)
),
r#"<append=%true;columns=[a];ranges=[{lower_limit={row_index=0};upper_limit={row_index=1}}]>"//tmp/t""#
);
}
#[test]
fn it_is_built_from_every_shape_of_string_a_call_site_has() {
let owned = String::from("//tmp/out");
let borrowed: &str = "//tmp/out";
let paths = vec!["//tmp/out"];
assert_eq!(TablePath::from("//tmp/out").as_str(), "//tmp/out");
assert_eq!(TablePath::from(owned.clone()).as_str(), "//tmp/out");
assert_eq!(TablePath::from(&owned).as_str(), "//tmp/out");
assert_eq!(TablePath::from(&borrowed).as_str(), "//tmp/out");
assert_eq!(
TablePath::from(std::borrow::Cow::Borrowed("//tmp/out")).as_str(),
"//tmp/out"
);
for path in &paths {
assert_eq!(TablePath::from(path).as_str(), "//tmp/out");
}
}
#[test]
fn it_prints_the_way_the_cluster_spells_it() {
assert_eq!(TablePath::from("//tmp/out").to_string(), "//tmp/out");
assert_eq!(
TablePath::new("//tmp/out").append().to_string(),
"<append=%true>//tmp/out"
);
assert_eq!(
TablePath::new("//tmp/t")
.columns(["a"])
.range(0..2)
.to_string(),
"<columns=[a];ranges=[{lower_limit={row_index=0};upper_limit={row_index=2}}]>//tmp/t"
);
}
#[test]
fn append_is_a_property_of_the_path_and_not_of_the_string() {
let path = TablePath::new("//tmp/out");
assert!(!path.is_append());
assert!(path.clone().append().is_append());
assert!(!path.is_append());
}
#[test]
fn a_write_refuses_a_typed_read_selection() {
let columns = TablePath::new("//tmp/t").columns(["a"]);
let reason = columns.write_refusal().expect("refused");
assert!(reason.contains("a write cannot select columns"), "{reason}");
assert!(
!reason.contains("a write cannot take a row range"),
"{reason}"
);
let ranged = TablePath::new("//tmp/t").range(0..2);
let reason = ranged.write_refusal().expect("refused");
assert!(
reason.contains("a write cannot take a row range"),
"{reason}"
);
assert!(
!reason.contains("a write cannot select columns"),
"{reason}"
);
assert!(TablePath::new("//tmp/t").write_refusal().is_none());
assert!(TablePath::new("//tmp/t").append().write_refusal().is_none());
}
#[test]
fn a_range_asking_for_rows_no_table_has_is_refused() {
let (from, to) = (5_i64, 3_i64);
let backwards = TablePath::new("//tmp/t").range(from..to);
let reason = backwards.read_refusal().expect("refused");
assert!(reason.contains("starts at 5 and ends at 3"), "{reason}");
for range in [-5..2, -5..0] {
let negative = TablePath::new("//tmp/t").range(range);
let reason = negative.read_refusal().expect("refused");
assert!(reason.contains("is negative"), "{reason}");
assert!(reason.contains("clamps it to 0"), "{reason}");
assert!(
reason.contains("reads from the start of the table"),
"{reason}"
);
}
let negative_upper = TablePath::new("//tmp/t").range(..-2);
assert!(
negative_upper
.read_refusal()
.expect("refused")
.contains("is negative")
);
let reason = TablePath::new("//tmp/t")
.range(RowRange::keys(Key::from("b")..Key::from("a")))
.read_refusal()
.expect("refused");
assert!(reason.contains("starts after it ends"), "{reason}");
assert!(
TablePath::new("//tmp/t")
.range(RowRange::keys(Key::from("b")..=Key::from("a")))
.read_refusal()
.is_some()
);
for path in [
TablePath::new("//tmp/t").range(5..5),
TablePath::new("//tmp/t").range(0..1),
TablePath::new("//tmp/t").range(RowRange::keys(Key::from("a")..Key::from("a"))),
TablePath::new("//tmp/t").range(RowRange::keys(Key::from("a")..Key::from("b"))),
TablePath::new("//tmp/t").range(RowRange::keys(
Key::from("a")..Key::new([yson_build::string("a"), yson_build::int(1)]),
)),
TablePath::new("//tmp/t").range(RowRange::keys(
Key::new([yson_build::uint(9)])..Key::new([yson_build::int(2)]),
)),
] {
assert!(path.read_refusal().is_none(), "{path} was refused");
}
}
#[test]
fn an_empty_column_selection_is_sent() {
let empty = TablePath::new("//tmp/t").columns(Vec::<String>::new());
assert!(empty.read_refusal().is_none());
assert_eq!(
ytsaurus_yson::to_string(&empty.to_yson(), YsonFormat::Text).unwrap(),
r#"<columns=[]>"//tmp/t""#
);
let counted = TablePath::new("//tmp/t")
.columns(Vec::<String>::new())
.range(0..2);
assert!(counted.read_refusal().is_none());
}
#[test]
fn a_write_refuses_selection_syntax_spelled_into_the_string() {
for path in ["//tmp/t[#0:#2]", "//tmp/t{a,b}", "<append=%true>//tmp/t"] {
let refusal = TablePath::new(path).write_refusal();
assert!(refusal.is_some(), "{path} was not refused");
}
assert!(TablePath::new(r"//tmp/t\[x\]").write_refusal().is_none());
assert!(TablePath::new(r"//tmp/t\{x\}").write_refusal().is_none());
}
#[test]
fn a_read_takes_the_string_verbatim_unless_the_same_selection_joins_it() {
for path in [
"//tmp/t[#0:#2]",
"//tmp/t{a}",
"<columns=[a]>//tmp/t",
"//tmp/t{a}[#0:#2]",
] {
assert!(
TablePath::new(path).read_refusal().is_none(),
"bare {path} was refused"
);
}
assert!(
TablePath::new("//tmp/t")
.columns(["a"])
.read_refusal()
.is_none()
);
let reason = TablePath::new("//tmp/t[#0:#2]")
.range(0..2)
.read_refusal()
.expect("refused");
assert!(reason.contains("already selects rows"), "{reason}");
let reason = TablePath::new("//tmp/t{a}")
.columns(["b"])
.read_refusal()
.expect("refused");
assert!(reason.contains("already selects columns"), "{reason}");
assert!(
TablePath::new("//tmp/t[#0:#2]")
.columns(["a"])
.read_refusal()
.is_none()
);
assert!(
TablePath::new("//tmp/t{a}")
.range(0..2)
.read_refusal()
.is_none()
);
assert!(
TablePath::new("//tmp/t{a}[#0:#2]")
.range(0..2)
.read_refusal()
.is_some()
);
for path in ["<columns=[a]>//tmp/t", "<primary_medium=default>//tmp/t"] {
let reason = TablePath::new(path)
.range(0..2)
.read_refusal()
.expect("refused");
assert!(reason.contains("cannot tell whether"), "{reason}");
assert!(reason.contains("discarded silently"), "{reason}");
}
}
}