selene-db-gql 1.3.0

ISO/IEC 39075:2024 GQL parser, planner, optimizer, and executor for selene-db.
Documentation
use jiff::SignedDuration;
use selene_core::{DurationTypeQualifier, Value};

use crate::{
    GqlType, SourceSpan,
    runtime::{DataExceptionSubclass, EvalCtx, ExecutorError},
    temporal_parse,
};

use super::non_iso_static_source_for_target;

pub(super) fn cast_to_temporal(
    value: Value,
    target_type: &GqlType,
    span: SourceSpan,
    ctx: &EvalCtx<'_, '_, '_, '_>,
) -> Result<Value, ExecutorError> {
    match (value, target_type) {
        (Value::ZonedDateTime(value), GqlType::ZonedDateTime) => Ok(Value::ZonedDateTime(value)),
        (Value::ZonedDateTime(value), GqlType::LocalDateTime) => {
            shifted_zoned_datetime_local(&value, span).map(Value::LocalDateTime)
        }
        (Value::ZonedDateTime(value), GqlType::Date) => Ok(Value::Date(value.datetime().date())),
        (Value::ZonedDateTime(value), GqlType::LocalTime) => {
            shifted_zoned_datetime_local(&value, span)
                .map(|datetime| Value::LocalTime(datetime.time()))
        }
        (Value::ZonedDateTime(value), GqlType::ZonedTime) => {
            let text = format!("{}{}", value.time(), value.offset());
            temporal_parse::parse_zoned_time(&text)
                .map(|value| Value::ZonedTime(Box::new(value)))
                .map_err(|error| invalid_datetime_format(error, span))
        }
        (Value::LocalDateTime(value), GqlType::LocalDateTime) => Ok(Value::LocalDateTime(value)),
        (Value::LocalDateTime(value), GqlType::Date) => Ok(Value::Date(value.date())),
        (Value::LocalDateTime(value), GqlType::LocalTime) => Ok(Value::LocalTime(value.time())),
        (Value::LocalDateTime(value), GqlType::ZonedDateTime) => {
            zoned_from_local_datetime(value, ctx, span)
                .map(|value| Value::ZonedDateTime(Box::new(value)))
        }
        (Value::Date(value), GqlType::Date) => Ok(Value::Date(value)),
        (Value::Date(value), GqlType::LocalDateTime) => Ok(Value::LocalDateTime(
            value.to_datetime(jiff::civil::Time::midnight()),
        )),
        (Value::Date(value), GqlType::ZonedDateTime) => {
            zoned_from_local_datetime(value.to_datetime(jiff::civil::Time::midnight()), ctx, span)
                .map(|value| Value::ZonedDateTime(Box::new(value)))
        }
        (Value::ZonedTime(value), GqlType::ZonedTime) => Ok(Value::ZonedTime(value)),
        (Value::ZonedTime(value), GqlType::LocalDateTime) => Ok(Value::LocalDateTime(
            current_session_date(ctx).to_datetime(shifted_zoned_time_local(&value, span)?),
        )),
        (Value::ZonedTime(value), GqlType::LocalTime) => {
            shifted_zoned_time_local(&value, span).map(Value::LocalTime)
        }
        (Value::ZonedTime(value), GqlType::ZonedDateTime) => {
            let datetime = current_session_date(ctx).to_datetime(value.time());
            zoned_from_datetime_and_zone(datetime, value.offset().to_time_zone(), span)
                .map(|value| Value::ZonedDateTime(Box::new(value)))
        }
        (Value::LocalTime(value), GqlType::LocalTime) => Ok(Value::LocalTime(value)),
        (Value::LocalTime(value), GqlType::LocalDateTime) => Ok(Value::LocalDateTime(
            current_session_date(ctx).to_datetime(value),
        )),
        (Value::LocalTime(value), GqlType::ZonedDateTime) => {
            let datetime = current_session_date(ctx).to_datetime(value);
            zoned_from_local_datetime(datetime, ctx, span)
                .map(|value| Value::ZonedDateTime(Box::new(value)))
        }
        (Value::LocalTime(value), GqlType::ZonedTime) => {
            zoned_from_local_time(value, ctx, span).map(|value| Value::ZonedTime(Box::new(value)))
        }
        (Value::Duration(value), target) if target.is_duration() => {
            validate_duration_target(&value, target, span).map(|()| Value::Duration(value))
        }
        (Value::String(value), GqlType::ZonedDateTime) => {
            temporal_parse::parse_zoned_datetime(value.as_str().trim())
                .map(|value| Value::ZonedDateTime(Box::new(value)))
                .map_err(|error| invalid_datetime_format(error, span))
        }
        (Value::String(value), GqlType::LocalDateTime) => {
            temporal_parse::parse_local_datetime(value.as_str().trim())
                .map(Value::LocalDateTime)
                .map_err(|error| invalid_datetime_format(error, span))
        }
        (Value::String(value), GqlType::Date) => temporal_parse::parse_date(value.as_str().trim())
            .map(Value::Date)
            .map_err(|error| invalid_datetime_format(error, span)),
        (Value::String(value), GqlType::ZonedTime) => {
            temporal_parse::parse_zoned_time(value.as_str().trim())
                .map(|value| Value::ZonedTime(Box::new(value)))
                .map_err(|error| invalid_datetime_format(error, span))
        }
        (Value::String(value), GqlType::LocalTime) => {
            temporal_parse::parse_local_time(value.as_str().trim())
                .map(Value::LocalTime)
                .map_err(|error| invalid_datetime_format(error, span))
        }
        (Value::String(value), target) if target.is_duration() => {
            temporal_parse::parse_duration(value.as_str().trim())
                .map_err(|error| invalid_duration_format(error, span))
                .and_then(|value| {
                    validate_duration_target(&value, target, span)
                        .map(|()| Value::Duration(Box::new(value)))
                })
        }
        (source, target) => {
            Err(
                non_iso_static_source_for_target(&source, temporal_cast_target_name(target), span)
                    .unwrap_or(ExecutorError::FeatureNotSupportedYet {
                        feature: temporal_cast_to_type_feature(target),
                        span,
                    }),
            )
        }
    }
}

fn invalid_datetime_format(message: String, span: SourceSpan) -> ExecutorError {
    ExecutorError::data_exception(DataExceptionSubclass::InvalidDatetimeFormat, message, span)
}

fn invalid_duration_format(message: String, span: SourceSpan) -> ExecutorError {
    ExecutorError::data_exception(DataExceptionSubclass::InvalidDurationFormat, message, span)
}

fn zoned_from_local_datetime(
    value: jiff::civil::DateTime,
    ctx: &EvalCtx<'_, '_, '_, '_>,
    span: SourceSpan,
) -> Result<jiff::Zoned, ExecutorError> {
    zoned_from_datetime_and_zone(value, ctx.tx.session_time_zone().clone(), span)
}

fn zoned_from_datetime_and_zone(
    value: jiff::civil::DateTime,
    zone: jiff::tz::TimeZone,
    span: SourceSpan,
) -> Result<jiff::Zoned, ExecutorError> {
    value
        .to_zoned(zone)
        .map_err(|error| invalid_datetime_format(format!("invalid ZONED DATETIME: {error}"), span))
}

fn shifted_zoned_datetime_local(
    value: &jiff::Zoned,
    span: SourceSpan,
) -> Result<jiff::civil::DateTime, ExecutorError> {
    value
        .datetime()
        .checked_add(offset_duration(value.offset()))
        .map_err(|error| invalid_datetime_format(format!("invalid LOCAL DATETIME: {error}"), span))
}

fn shifted_zoned_time_local(
    value: &jiff::Zoned,
    span: SourceSpan,
) -> Result<jiff::civil::Time, ExecutorError> {
    let anchor = jiff::civil::Date::constant(1970, 1, 1).to_datetime(value.time());
    anchor
        .checked_add(offset_duration(value.offset()))
        .map(|datetime| datetime.time())
        .map_err(|error| invalid_datetime_format(format!("invalid LOCAL TIME: {error}"), span))
}

fn offset_duration(offset: jiff::tz::Offset) -> SignedDuration {
    SignedDuration::from_secs(i64::from(offset.seconds()))
}

fn zoned_from_local_time(
    value: jiff::civil::Time,
    ctx: &EvalCtx<'_, '_, '_, '_>,
    span: SourceSpan,
) -> Result<jiff::Zoned, ExecutorError> {
    let anchor = jiff::civil::Date::constant(1970, 1, 1).to_datetime(value);
    zoned_from_local_datetime(anchor, ctx, span)
}

fn current_session_date(ctx: &EvalCtx<'_, '_, '_, '_>) -> jiff::civil::Date {
    ctx.tx.request_timestamp_zoned().date()
}

fn temporal_cast_to_type_feature(target: &GqlType) -> &'static str {
    match target {
        GqlType::ZonedDateTime => "CAST to ZONED DATETIME",
        GqlType::LocalDateTime => "CAST to LOCAL DATETIME",
        GqlType::Date => "CAST to DATE",
        GqlType::ZonedTime => "CAST to ZONED TIME",
        GqlType::LocalTime => "CAST to LOCAL TIME",
        GqlType::Duration | GqlType::DurationYearToMonth | GqlType::DurationDayToSecond => {
            "CAST to DURATION"
        }
        _ => "CAST to temporal target",
    }
}

fn temporal_cast_target_name(target: &GqlType) -> &'static str {
    match target {
        GqlType::ZonedDateTime => "ZONED DATETIME",
        GqlType::LocalDateTime => "LOCAL DATETIME",
        GqlType::Date => "DATE",
        GqlType::ZonedTime => "ZONED TIME",
        GqlType::LocalTime => "LOCAL TIME",
        GqlType::Duration => "DURATION",
        GqlType::DurationYearToMonth => "DURATION (YEAR TO MONTH)",
        GqlType::DurationDayToSecond => "DURATION (DAY TO SECOND)",
        _ => "temporal target",
    }
}

fn validate_duration_target(
    value: &jiff::Span,
    target: &GqlType,
    span: SourceSpan,
) -> Result<(), ExecutorError> {
    let matches = match target {
        GqlType::Duration => true,
        GqlType::DurationYearToMonth => DurationTypeQualifier::YearToMonth.matches_span(value),
        GqlType::DurationDayToSecond => DurationTypeQualifier::DayToSecond.matches_span(value),
        _ => unreachable!("validate_duration_target only receives duration targets"),
    };
    if matches {
        Ok(())
    } else {
        Err(ExecutorError::data_exception(
            DataExceptionSubclass::InvalidValueType,
            format!(
                "duration value {value} does not conform to {}",
                temporal_cast_target_name(target)
            ),
            span,
        ))
    }
}