Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion linux/crates/app/src/ui/editor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,10 @@ fn truncated_result_reveals_banner_and_complete_result_hides_it() {
let complete = shown_banner(&harness);

assert!(capped.is_revealed(), "the capped result has no banner");
assert_eq!(capped.title(), "Showing the first 3 rows");
assert_eq!(
capped.title(),
"Stopped after the first 3 rows: the statement did not run past them"
);
assert_eq!(capped.button_label().as_deref(), Some("Export All…"));
assert!(!complete.is_revealed(), "a complete result shows a banner");
}
Expand Down
12 changes: 10 additions & 2 deletions linux/crates/app/src/ui/result_pane/result_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,20 @@ impl ResultSummary {
plural_count(total),
&[("shown", &shown), ("total", &format(total))],
)),
(Truncation::ReadPastLimit | Truncation::ServerStopped, _) => Some(ngettext_f(
(Truncation::ReadPastLimit, None) => Some(ngettext_f(
"Showing the first {shown} row",
"Showing the first {shown} rows",
plural_count(self.retained),
&[("shown", &shown)],
)),
// A SELECT that calls a function per row did that for these
// rows only, which "showing" alone would hide.
(Truncation::ServerStopped, _) => Some(ngettext_f(
"Stopped after the first {shown} row: the statement did not run past it",
"Stopped after the first {shown} rows: the statement did not run past them",
plural_count(self.retained),
&[("shown", &shown)],
)),
}
}

Expand Down Expand Up @@ -127,7 +135,7 @@ mod tests {

assert_eq!(
stopped.limit_title_with(english).as_deref(),
Some("Showing the first 1,000 rows")
Some("Stopped after the first 1,000 rows: the statement did not run past them")
);
assert_eq!(
read_past.limit_title_with(english).as_deref(),
Expand Down
14 changes: 14 additions & 0 deletions linux/crates/app/src/ui/structure_tab/review_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ fn destructive_text(change: &DestructiveChange) -> String {
"Changes column “{column}” from {from} to {to}. A value the new type can't hold is converted or refused.",
&[("column", column), ("from", from), ("to", to)],
),
DestructiveChange::SetNotNull { column } => crate::i18n::gettext_f(
"Makes column “{column}” NOT NULL. A NULL already in it is converted or refused.",
&[("column", column)],
),
DestructiveChange::DropIndex { index } => crate::i18n::gettext_f("Drops index “{index}”.", &[("index", index)]),
DestructiveChange::DropForeignKey { key } => {
crate::i18n::gettext_f("Drops foreign key “{key}”.", &[("key", key)])
Expand Down Expand Up @@ -235,6 +239,16 @@ mod tests {
assert_eq!(atomic.body(), "");
}

#[test]
fn a_new_not_null_says_what_happens_to_nulls() {
assert_eq!(
destructive_text(&DestructiveChange::SetNotNull {
column: "email".to_owned()
}),
"Makes column “email” NOT NULL. A NULL already in it is converted or refused."
);
}

#[test]
fn a_rebuild_names_what_it_makes_again() {
let text = rebuild_text(&TableRebuild {
Expand Down
11 changes: 10 additions & 1 deletion linux/crates/core/src/ddl/change_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub struct ChangeReview {
/// The edits the user made, counted as the action bar counts them.
pub operations: usize,
pub statements: Vec<String>,
/// The rebuild first, then the drops and type changes.
/// The rebuild first, then the drops, type changes and new NOT NULLs.
pub destructive: Vec<DestructiveChange>,
}

Expand Down Expand Up @@ -48,6 +48,15 @@ impl ChangeReview {
}
}),
);
destructive.extend(
changes
.altered_columns
.iter()
.filter(|alteration| alteration.changed.nullability && !alteration.draft.nullable)
.map(|alteration| DestructiveChange::SetNotNull {
column: alteration.original.name().to_owned(),
}),
);
destructive.extend(
changes
.dropped_indexes
Expand Down
5 changes: 5 additions & 0 deletions linux/crates/core/src/ddl/destructive_change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ pub enum DestructiveChange {
from: String,
to: String,
},
/// A NULL already in the column has to go: the server refuses the
/// change, or on some engines puts a default value in its place.
SetNotNull {
column: String,
},
DropIndex {
index: String,
},
Expand Down
33 changes: 31 additions & 2 deletions linux/crates/core/src/ddl/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -751,8 +751,37 @@ fn unchanged_catalog_type_is_not_type_change() {
email.nullable = false;
});

assert!(catalog.destructive.is_empty(), "{:?}", catalog.destructive);
assert!(retyped.destructive.is_empty(), "{:?}", retyped.destructive);
let not_null = [DestructiveChange::SetNotNull {
column: "email".to_owned(),
}];
assert_eq!(catalog.destructive, not_null);
assert_eq!(retyped.destructive, not_null);
}

/// MySQL without strict mode and ClickHouse turn the NULLs in a column
/// made NOT NULL into 0 or an empty string, so the change is reviewed.
#[test]
fn set_not_null_is_destructive_and_drop_not_null_is_not() {
let tightened = review(&["ALTER TABLE users ALTER COLUMN legacy SET NOT NULL"], |draft| {
draft_column(draft, "legacy").nullable = false;
});
assert_eq!(
tightened.destructive,
[DestructiveChange::SetNotNull {
column: "legacy".to_owned()
}]
);
assert!(tightened.requires_confirmation(crate::WriteAtomicity::Transactional));

let mut original = users();
if let Some(legacy) = original.columns.iter_mut().find(|column| column.name() == "legacy") {
legacy.info.nullable = false;
}
let mut draft = TableDraft::from_definition(&original);
draft_column(&mut draft, "legacy").nullable = true;
let plan = DdlPlan::from_sql(["ALTER TABLE users ALTER COLUMN legacy DROP NOT NULL".to_owned()]);
let loosened = ChangeReview::review(&diff_table(&original, &draft), &plan);
assert!(loosened.destructive.is_empty(), "{:?}", loosened.destructive);
}

#[test]
Expand Down
88 changes: 86 additions & 2 deletions linux/crates/core/src/edit/parse_literal_text.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime};
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Timelike};

use crate::column::{BoolStorage, ColumnKind, ColumnType, FloatWidth, IntegerKind, TemporalZone, TimeRange};
use crate::value::{
Expand Down Expand Up @@ -56,6 +56,14 @@ fn keeps_text_on_failure(kind: &ColumnKind) -> bool {
}

fn strict(input: &str, column: &ColumnType, purpose: Purpose) -> Result<Value, EditParseError> {
let value = strict_value(input, column, purpose)?;
if purpose == Purpose::Store {
check_declared_fraction(&value, column.kind.fractional_digits())?;
}
Ok(value)
}

fn strict_value(input: &str, column: &ColumnType, purpose: Purpose) -> Result<Value, EditParseError> {
let trimmed = input.trim();
match &column.kind {
ColumnKind::Bool(BoolStorage::Native) => parse_bool(trimmed).map(Value::Bool),
Expand Down Expand Up @@ -185,6 +193,28 @@ fn check_declared_digits(
}
}

/// Refuse a fraction of a second finer than the column keeps. The
/// server rounds it, and `23:59:59.5` in a `timestamp(0)` becomes the
/// next day.
fn check_declared_fraction(value: &Value, digits: Option<u8>) -> Result<(), EditParseError> {
let Some(digits) = digits.filter(|digits| *digits < 9) else {
return Ok(());
};
let nanos = match value {
Value::Time(time) => time.total_nanos().rem_euclid(1_000_000_000),
Value::TimeTz(time) => time.time.total_nanos().rem_euclid(1_000_000_000),
Value::Timestamp(Temporal::Finite(stamp)) => i128::from(stamp.nanosecond()),
Value::TimestampTz(Temporal::Finite(stamp)) => i128::from(stamp.utc().nanosecond()),
_ => return Ok(()),
};
match nanos % 10_i128.pow(9 - u32::from(digits)) {
0 => Ok(()),
_ => Err(EditParseError::ScaleExceeded {
scale: u32::from(digits),
}),
}
}

fn parse_float(input: &str, width: FloatWidth) -> Result<Value, EditParseError> {
if let Some(value) = non_finite(input) {
return Ok(match width {
Expand Down Expand Up @@ -231,9 +261,16 @@ fn parse_time(input: &str) -> Result<Value, EditParseError> {
if let Ok(time) = NaiveTime::parse_from_str(input, "%H:%M") {
return Ok(Value::Time(SqlTime::from_time_of_day(time)));
}
Err(EditParseError::InvalidTime)
// PostgreSQL's time runs to 24:00:00 inclusive, which no NaiveTime
// holds.
match input.parse::<SqlTime>() {
Ok(time) if time.total_nanos() == END_OF_DAY_NANOS => Ok(Value::Time(time)),
_ => Err(EditParseError::InvalidTime),
}
}

const END_OF_DAY_NANOS: i128 = 86_400 * 1_000_000_000;

/// MySQL TIME, which measures an amount of time and so runs past 24
/// hours and below zero.
fn parse_duration(input: &str) -> Result<Value, EditParseError> {
Expand Down Expand Up @@ -735,6 +772,53 @@ mod tests {
);
}

#[test]
fn a_fraction_finer_than_the_column_keeps_is_refused() {
let declared = |digits| ColumnKind::Timestamp {
fractional_digits: Some(digits),
zone: TemporalZone::Naive,
};

assert_eq!(
parse(declared(0), "2024-12-31 23:59:59.5"),
Err(EditParseError::ScaleExceeded { scale: 0 }),
"the server would have rounded into the next day"
);
assert!(parse(declared(3), "2024-12-31 23:59:59.120").is_ok());
assert_eq!(
parse(declared(3), "2024-12-31 23:59:59.1234"),
Err(EditParseError::ScaleExceeded { scale: 3 })
);
assert_eq!(
parse(
ColumnKind::Time {
fractional_digits: Some(0),
with_offset: false,
range: TimeRange::Duration,
},
"-01:00:00.5"
),
Err(EditParseError::ScaleExceeded { scale: 0 })
);
assert!(
parse_filter_text("2024-12-31 23:59:59.5", &column(declared(0), false)).is_ok(),
"a comparison was held to the column's precision"
);
assert!(parse(stamp(TemporalZone::Naive), "2024-12-31 23:59:59.123456789").is_ok());
}

#[test]
fn the_end_of_the_day_is_a_time_of_day() {
let Ok(Value::Time(end)) = parse(time(false, TimeRange::TimeOfDay), "24:00:00") else {
panic!("24:00:00 did not parse");
};
assert_eq!(end.format(None), "24:00:00");
assert_eq!(
parse(time(false, TimeRange::TimeOfDay), "24:00:01"),
Err(EditParseError::InvalidTime)
);
}

#[test]
fn a_network_column_keeps_the_servers_own_text() {
let Ok(Value::Other(other)) = parse(ColumnKind::Network(crate::column::NetworkFamily::Cidr), "10.0.0.0/8")
Expand Down
5 changes: 3 additions & 2 deletions linux/crates/core/src/result_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ pub struct ServerNotice {
pub enum Truncation {
/// Every row the statement produced is here.
Complete,
/// The server stopped sending, because the query had a LIMIT of its
/// own or the run was stopped.
/// The statement itself was stopped at the row limit, so it never
/// ran for the rows past it: a function it calls per row ran for the
/// rows kept and no others.
ServerStopped,
/// The run hit its row limit and there were more rows behind it.
ReadPastLimit,
Expand Down
2 changes: 1 addition & 1 deletion linux/crates/drivers/mysql/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub(crate) use mysql_extra::{ExtraGenerated, MySqlExtra};
pub(crate) use table_definition::{
COLUMN_ENGINE_ATTRIBUTES_SQL, ColumnCheck, INBOUND_FOREIGN_KEYS_SQL, ListedSrid, MARIADB_COLUMN_CHECKS_SQL,
MARIADB_GEOMETRY_SRIDS_SQL, column_checks, geometry_srids, inbound_foreign_keys, place_column_checks,
printed_names,
printed_names, quoted_default,
};

use sqlx::mysql::{MySql, MySqlColumn};
Expand Down
22 changes: 22 additions & 0 deletions linux/crates/drivers/mysql/src/catalog/table_definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ fn column_line<'a>(create_statement: &'a str, column: &str) -> Option<&'a str> {
})
}

/// The quoted DEFAULT on the column's line of SHOW CREATE TABLE, without
/// its quotes. The server prints a TIMESTAMP default there as wall time
/// in the session's zone, where MySQL's `COLUMN_DEFAULT` keeps the text
/// of whichever session last altered the table, in that session's zone.
pub(crate) fn quoted_default(create_statement: &str, column: &str) -> Option<String> {
const MARKER: &str = " DEFAULT '";
let line = column_line(create_statement, column)?;
let rest = line.get(line.find(MARKER)? + MARKER.len()..)?;
let end = rest.find('\'')?;
rest.get(..end).map(str::to_owned)
}

fn ends_with_check(line: &str, clause: &str) -> bool {
line.strip_suffix(')')
.and_then(|rest| rest.strip_suffix(clause))
Expand Down Expand Up @@ -171,6 +183,16 @@ mod tests {
)
}

#[test]
fn a_quoted_default_is_read_from_its_own_columns_line() {
let create = "CREATE TABLE `t` (\n `id` int NOT NULL,\n `at` timestamp NULL DEFAULT '1999-12-31 17:00:00' \
COMMENT 'x',\n `made` timestamp NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (`id`)\n)";

assert_eq!(quoted_default(create, "at").as_deref(), Some("1999-12-31 17:00:00"));
assert_eq!(quoted_default(create, "made"), None);
assert_eq!(quoted_default(create, "missing"), None);
}

#[test]
fn only_a_geometry_column_that_sets_an_srid_keeps_one() {
let rows = [
Expand Down
23 changes: 20 additions & 3 deletions linux/crates/drivers/mysql/src/dialect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ impl SqlDialect for MySqlDialect {
BindTarget::Column(column) => column_param_type(column),
};
let (value, param_type) = adapt(value, param_type)?;
Ok(Placeholder::bound("?", BoundParam::new(value, param_type)?))
let marker = match param_type {
ParamType::TimestampTz => crate::server_zone::TIMESTAMP_PLACEHOLDER,
_ => "?",
};
Ok(Placeholder::bound(marker, BoundParam::new(value, param_type)?))
}

fn literal(&self, value: &Value, _column: &ColumnType) -> Result<String, LiteralError> {
Expand Down Expand Up @@ -241,8 +245,8 @@ fn column_param_type(column: &ColumnInfo) -> ParamType {
ColumnKind::Date => ParamType::Date,
ColumnKind::Time { with_offset: false, .. } => ParamType::Time,
ColumnKind::Time { with_offset: true, .. } => ParamType::TimeTz,
// A workspace session's zone is UTC, so the instant binds as
// its UTC wall time.
// The instant binds as its UTC wall time, which the placeholder
// converts to whatever zone the session is in.
ColumnKind::Timestamp {
zone: TemporalZone::Offset,
..
Expand Down Expand Up @@ -604,10 +608,23 @@ mod tests {
},
)
.expect("a typed null");
assert_eq!(
stamp.sql, "CONVERT_TZ(?, '+00:00', @@session.time_zone)",
"a write runs in the server's zone, so the UTC wall time is converted there"
);
assert_eq!(
stamp.param.map(|param| param.param_type().clone()),
Some(ParamType::TimestampTz)
);
let naive = bind(
Value::Null,
ColumnKind::Timestamp {
fractional_digits: None,
zone: TemporalZone::Naive,
},
)
.expect("a DATETIME null");
assert_eq!(naive.sql, "?", "a DATETIME is wall time in any zone");
assert!(matches!(
bind(Value::Text("x".to_owned()), ColumnKind::Date),
Err(BindError::ValueTypeMismatch { .. })
Expand Down
4 changes: 2 additions & 2 deletions linux/crates/drivers/mysql/src/encode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ fn bind_one<'q>(query: MySqlQuery<'q>, param: &BoundParam) -> Result<MySqlQuery<
},
(_, Value::Date(Temporal::Finite(date))) => query.bind(*date),
(_, Value::Timestamp(Temporal::Finite(stamp))) => query.bind(*stamp),
// The workspace session reads and writes TIMESTAMP in UTC, so the
// instant goes as its UTC wall time.
// The instant goes as its UTC wall time, and the dialect's
// placeholder converts it to the session's zone.
(_, Value::TimestampTz(Temporal::Finite(stamp))) => query.bind(stamp.utc()),
(_, Value::Date(_) | Value::Timestamp(_) | Value::TimestampTz(_)) => return Err(unrepresentable(value)),
(_, Value::Decimal(decimal)) if !decimal.is_finite() => return Err(unrepresentable(value)),
Expand Down
1 change: 1 addition & 0 deletions linux/crates/drivers/mysql/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod mysql_temporal;
mod mysql_timestamp_mode;
mod mysql_wire_format;
mod physical;
mod server_zone;
mod session_statements;
mod standalone;
mod stream_read;
Expand Down
Loading
Loading