use super::*;
use crate::schema::{Column, LengthRange};
fn column<'a>(table: &'a Table, name: &str) -> &'a Column {
table
.columns
.iter()
.find(|column| column.sql_name == name)
.unwrap_or_else(|| panic!("no column `{name}`"))
}
fn lengths(table: &Table) -> Vec<(&str, Option<u32>)> {
table
.columns
.iter()
.map(|column| (column.sql_name.as_str(), column.declared_length))
.collect()
}
#[test]
fn character_types_keep_their_declared_length() {
let table = one_table(
"CREATE TABLE people (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(80) NOT NULL,
nick CHARACTER VARYING(12),
code CHAR(3),
grade CHARACTER(1),
flag CHAR,
note VARCHAR,
body TEXT
);",
);
assert_eq!(
lengths(&table),
[
("id", None),
("name", Some(80)),
("nick", Some(12)),
("code", Some(3)),
("grade", Some(1)),
("flag", Some(1)),
("note", None),
("body", None),
]
);
}
#[test]
fn lengths_follow_added_columns_and_type_changes() {
let parsed = parse_files(&[
(
"0001.up.sql",
"CREATE TABLE people (id BIGSERIAL PRIMARY KEY, name VARCHAR(80), code CHAR(3), note TEXT);",
),
(
"0002.up.sql",
"ALTER TABLE people ADD COLUMN email VARCHAR(255) NOT NULL;
ALTER TABLE people ALTER COLUMN name TYPE VARCHAR(120);
ALTER TABLE people ALTER COLUMN code TYPE TEXT;
ALTER TABLE people ALTER COLUMN note SET DATA TYPE CHARACTER VARYING(500);",
),
]);
assert!(parsed.failures.is_empty(), "{:?}", parsed.failures);
assert_eq!(
lengths(&parsed.schema.tables[0]),
[
("id", None),
("name", Some(120)),
("code", None),
("note", Some(500)),
("email", Some(255)),
]
);
}
fn ranges(table: &Table) -> Vec<(&str, Option<String>)> {
table
.columns
.iter()
.map(|column| {
let range = column.value_range.as_ref().map(|range| {
let min = range.min.as_ref().map_or("-".to_owned(), |bound| {
let op = if bound.exclusive { ">" } else { ">=" };
format!("{op}{}", bound.value)
});
let max = range.max.as_ref().map_or("-".to_owned(), |bound| {
let op = if bound.exclusive { "<" } else { "<=" };
format!("{op}{}", bound.value)
});
format!("{min}, {max}")
});
(column.sql_name.as_str(), range)
})
.collect()
}
fn range(text: &str) -> Option<String> {
Some(text.to_owned())
}
#[test]
fn comparisons_give_integer_columns_an_inclusive_range() {
let table = one_table(
"CREATE TABLE stock (
id BIGSERIAL PRIMARY KEY,
at_least INTEGER CHECK (at_least >= 0),
above INTEGER CHECK (above > 0),
at_most SMALLINT CHECK (at_most <= 10),
below BIGINT CHECK ((below < -5)),
left_side INTEGER CHECK (1 < left_side),
left_max INTEGER CHECK (100 >= left_max),
between_them INTEGER CHECK (between_them BETWEEN -3 AND 7),
both_ends INTEGER CHECK (both_ends > 0 AND both_ends <= 99),
table_level INTEGER,
named INTEGER,
plain INTEGER,
CHECK (table_level >= 18),
CONSTRAINT named_positive CHECK (0 < named AND named < 1000)
);",
);
assert_eq!(
ranges(&table),
[
("id", None),
("at_least", range(">=0, -")),
("above", range(">=1, -")),
("at_most", range("-, <=10")),
("below", range("-, <=-6")),
("left_side", range(">=2, -")),
("left_max", range("-, <=100")),
("between_them", range(">=-3, <=7")),
("both_ends", range(">=1, <=99")),
("table_level", range(">=18, -")),
("named", range(">=1, <=999")),
("plain", None),
]
);
}
#[test]
fn strict_bounds_on_other_numbers_are_kept_only_when_exact() {
let table = one_table(
"CREATE TABLE prices (
id BIGSERIAL PRIMARY KEY,
amount NUMERIC(12, 2) CHECK (amount > 0 AND amount <= 99999.990),
ratio DOUBLE PRECISION CHECK (ratio > 0.5 AND ratio < 0.1),
share REAL CHECK (share >= 0.1 AND share < 1),
tiny SMALLINT CHECK (tiny < 100000 AND tiny >= -5),
whole INTEGER CHECK (whole > 0.5 AND whole <= 10.0),
huge NUMERIC CHECK (huge < 123456789012345678901234567890),
label TEXT CHECK (label > 'a'),
born DATE CHECK (born > 0)
);",
);
assert_eq!(
ranges(&table),
[
("id", None),
("amount", range(">0, <=99999.99")),
("ratio", range(">0.5, -")),
("share", range(">=0.1, <1")),
("tiny", range(">=-5, -")),
("whole", range("-, <=10")),
("huge", None),
("label", None),
("born", None),
]
);
}
#[test]
fn several_checks_on_one_column_keep_the_tightest_bounds() {
let table = one_table(
"CREATE TABLE stock (
id BIGSERIAL PRIMARY KEY,
count INTEGER CHECK (count >= 0) CHECK (count > 5),
level NUMERIC CHECK (level >= 1.5),
CHECK (count BETWEEN 1 AND 50),
CHECK (level > 1.5 AND level <= 3),
CHECK (level < 2.25)
);",
);
assert_eq!(
ranges(&table),
[
("id", None),
("count", range(">=6, <=50")),
("level", range(">1.5, <2.25")),
]
);
}
fn length_ranges(table: &Table) -> Vec<(&str, Option<LengthRange>)> {
table
.columns
.iter()
.map(|column| (column.sql_name.as_str(), column.length_range))
.collect()
}
#[test]
fn length_functions_give_text_columns_a_length_range() {
let table = one_table(
"CREATE TABLE people (
id BIGSERIAL PRIMARY KEY,
name TEXT CHECK (char_length(name) > 0),
nick VARCHAR(40) CHECK (CHARACTER_LENGTH(nick) <= 20),
code TEXT CHECK (length(code) BETWEEN 2 AND 8),
pin TEXT CHECK (4 <= length(pin) AND length(pin) < 9),
any_length TEXT CHECK (length(any_length) >= 0),
amount INTEGER CHECK (length(amount) > 1),
CHECK (char_length(name) <= 100)
);",
);
assert_eq!(
length_ranges(&table),
[
("id", None),
("name", lengths_between(Some(1), Some(100))),
("nick", lengths_between(None, Some(20))),
("code", lengths_between(Some(2), Some(8))),
("pin", lengths_between(Some(4), Some(8))),
("any_length", None),
("amount", None),
]
);
assert_eq!(column(&table, "nick").declared_length, Some(40));
}
#[test]
fn other_checks_give_no_range() {
let table = one_table(
"CREATE TABLE stock (
id BIGSERIAL PRIMARY KEY,
a INTEGER,
b INTEGER,
name TEXT,
CHECK (a < b),
CHECK (a > 0 AND b > 0),
CHECK (a > 0 OR a IS NULL),
CHECK (b > 0 AND b <> 7),
CHECK (NOT (b < 0)),
CHECK (b NOT BETWEEN 1 AND 2),
CHECK (b = 3),
CHECK (abs(b) < 10),
CHECK (octet_length(name) < 10),
CHECK (upper(name) > 'A'),
CHECK (length(name, 'UTF8') < 10),
CHECK (stock.b > 1),
CHECK (b > 1 + 1),
CHECK (b::numeric > 1)
);",
);
assert!(
table
.columns
.iter()
.all(|column| column.value_range.is_none() && column.length_range.is_none())
);
}
#[test]
fn ranges_follow_added_constraints_columns_and_renames() {
let parsed = parse_files(&[
(
"0001.up.sql",
"CREATE TABLE stock (
id BIGSERIAL PRIMARY KEY,
count INTEGER CHECK (count >= 0),
name TEXT,
doomed INTEGER CHECK (doomed > 0)
);",
),
(
"0002.up.sql",
"ALTER TABLE stock ADD CONSTRAINT count_capped CHECK (count < 500);
ALTER TABLE stock ADD CHECK (char_length(name) BETWEEN 1 AND 60);
ALTER TABLE stock ADD COLUMN price NUMERIC(8, 2) CHECK (price > 0);
ALTER TABLE stock RENAME COLUMN count TO quantity;
ALTER TABLE stock DROP COLUMN doomed;
ALTER TABLE stock ADD COLUMN doomed INTEGER;",
),
]);
assert!(parsed.failures.is_empty(), "{:?}", parsed.failures);
let table = &parsed.schema.tables[0];
assert_eq!(
ranges(table),
[
("id", None),
("quantity", range(">=0, <=499")),
("name", None),
("price", range(">0, -")),
("doomed", None),
]
);
assert_eq!(
column(table, "name").length_range,
Some(LengthRange {
min: Some(1),
max: Some(60)
})
);
}
#[test]
fn type_changes_keep_ranges_only_for_the_same_rust_type() {
let parsed = parse_files(&[
(
"0001.up.sql",
"CREATE TABLE stock (
id BIGSERIAL PRIMARY KEY,
count INTEGER CHECK (count > 0),
level INTEGER CHECK (level > 0),
name VARCHAR(10) CHECK (char_length(name) > 2),
code TEXT CHECK (char_length(code) > 2)
);",
),
(
"0002.up.sql",
"ALTER TABLE stock ALTER COLUMN count TYPE INT4;
ALTER TABLE stock ALTER COLUMN level TYPE NUMERIC;
ALTER TABLE stock ALTER COLUMN name TYPE TEXT;
ALTER TABLE stock ALTER COLUMN code TYPE BYTEA USING code::bytea;",
),
]);
assert!(parsed.failures.is_empty(), "{:?}", parsed.failures);
let table = &parsed.schema.tables[0];
assert_eq!(
ranges(table),
[
("id", None),
("count", range(">=1, -")),
("level", None),
("name", None),
("code", None),
]
);
assert!(column(table, "name").length_range.is_some());
assert!(column(table, "code").length_range.is_none());
}
fn lengths_between(min: Option<u32>, max: Option<u32>) -> Option<LengthRange> {
Some(LengthRange { min, max })
}