Initial checkin

This commit is contained in:
2023-03-14 16:01:36 -05:00
parent ffc574e83f
commit b395ee6778
2 changed files with 490 additions and 0 deletions

View File

@ -0,0 +1,249 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.actions.dashboard.widgets;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.IsoFields;
import java.time.temporal.TemporalAdjusters;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Enum to define various "levels" of group-by for on dashboards that want to
** group records by, e.g., year, or month, or week, or day, or hour.
*******************************************************************************/
public enum DateTimeGroupBy
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// note - double %'s on the time format strings here, because this is a java-format string, which will get //
// its '%s' replaced with a column name, and so then those %'s for the date_format need escaped as %%. //
// See https://www.w3schools.com/sql/func_mysql_date_format.asp for DATE_FORMAT args //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
YEAR("%%Y", MillisPer.YEAR, 1, ChronoUnit.YEARS, DateTimeFormatter.ofPattern("yyyy"), DateTimeFormatter.ofPattern("yyyy")),
MONTH("%%Y-%%m", 2 * MillisPer.MONTH, 1, ChronoUnit.MONTHS, DateTimeFormatter.ofPattern("yyyy-MM"), DateTimeFormatter.ofPattern("MMM'.' yyyy")),
WEEK("%%XW%%V", 35 * MillisPer.DAY, 7, ChronoUnit.DAYS, DateTimeFormatter.ofPattern("YYYY'W'ww"), DateTimeFormatter.ofPattern("YYYY'W'w")),
DAY("%%Y-%%m-%%d", 36 * MillisPer.HOUR, 1, ChronoUnit.DAYS, DateTimeFormatter.ofPattern("yyyy-MM-dd"), DateTimeFormatter.ofPattern("EEE'.' M'/'d")),
HOUR("%%Y-%%m-%%dT%%H", 0, 1, ChronoUnit.HOURS, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH"), DateTimeFormatter.ofPattern("h a"));
/*******************************************************************************
**
*******************************************************************************/
public interface MillisPer
{
long HOUR = 60 * 60 * 1000;
long DAY = 24 * HOUR;
long WEEK = 7 * DAY;
long MONTH = 30 * DAY;
long YEAR = 365 * DAY;
}
private final String sqlDateFormat;
private final long millisThreshold;
private final int noOfChronoUnitsToAdd;
private final ChronoUnit chronoUnitToAdd;
private final DateTimeFormatter selectedStringFormatter;
private final DateTimeFormatter humanStringFormatter;
/*******************************************************************************
** Constructor
**
*******************************************************************************/
DateTimeGroupBy(String sqlDateFormat, long millisThreshold, int noOfChronoUnitsToAdd, ChronoUnit chronoUnitToAdd, DateTimeFormatter selectedStringFormatter, DateTimeFormatter humanStringFormatter)
{
this.sqlDateFormat = sqlDateFormat;
this.millisThreshold = millisThreshold;
this.noOfChronoUnitsToAdd = noOfChronoUnitsToAdd;
this.chronoUnitToAdd = chronoUnitToAdd;
this.selectedStringFormatter = selectedStringFormatter;
this.humanStringFormatter = humanStringFormatter;
}
/*******************************************************************************
**
*******************************************************************************/
public String getSqlExpression()
{
ZoneId sessionOrInstanceZoneId = ValueUtils.getSessionOrInstanceZoneId();
String targetTimezone = sessionOrInstanceZoneId.toString();
if("Z".equals(targetTimezone) || !StringUtils.hasContent(targetTimezone))
{
targetTimezone = "UTC";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
// if we only had a timezone offset (not a zone name/id), then the zoneId's toString will look like //
// UTC-05:00. MySQL doesn't want that, so, strip away the leading UTC, to just get -05:00 //
//////////////////////////////////////////////////////////////////////////////////////////////////////
if((targetTimezone.startsWith("UTC-") || targetTimezone.startsWith("UTC+")) && targetTimezone.length() > 5)
{
targetTimezone = targetTimezone.substring(3);
}
return "DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '" + targetTimezone + "'), '" + sqlDateFormat + "')";
/*
if(this == WEEK)
{
return "YEARWEEK(CONVERT_TZ(%s, 'UTC', '" + targetTimezone + "'), 6)";
}
else
{
return "DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '" + targetTimezone + "'), '" + sqlDateFormat + "')";
}
*/
}
/*******************************************************************************
** get an instance of this enum, based on start & end instants - look at the #
** of millis between them, and return the first enum value w/ a millisThreshold
** under that difference. Default to HOUR.
*******************************************************************************/
public static DateTimeGroupBy selectFromStartAndEndTimes(Instant start, Instant end)
{
long millisBetween = end.toEpochMilli() - start.toEpochMilli();
for(DateTimeGroupBy value : DateTimeGroupBy.values())
{
if(millisBetween > value.millisThreshold)
{
return (value);
}
}
return (HOUR);
}
/*******************************************************************************
** Make an Instant into a string that will match what came out of the database's
** DATE_FORMAT() function
*******************************************************************************/
public String makeSelectedString(Instant time)
{
ZonedDateTime zoned = time.atZone(ValueUtils.getSessionOrInstanceZoneId());
if(this == WEEK)
{
////////////////////////////////////////////////////////////////////////////////////////////////////
// so, it seems like database is returning, e.g., W00-W52, but java is doing W1-W53... //
// which, apparently we can compensate for by adding a week? not sure, but results seemed right. //
////////////////////////////////////////////////////////////////////////////////////////////////////
zoned = zoned.plusDays(7);
int weekYear = zoned.get(IsoFields.WEEK_BASED_YEAR);
int week = zoned.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
return (String.format("%04dW%02d", weekYear, week));
}
return (selectedStringFormatter.format(zoned));
}
/*******************************************************************************
** Make a string to show to a user
*******************************************************************************/
public String makeHumanString(Instant instant)
{
ZonedDateTime zoned = instant.atZone(ValueUtils.getSessionOrInstanceZoneId());
if(this.equals(WEEK))
{
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("M'/'d");
while(zoned.get(ChronoField.DAY_OF_WEEK) != DayOfWeek.SUNDAY.getValue())
{
////////////////////////////////////////
// go backwards until sunday is found //
////////////////////////////////////////
zoned = zoned.minus(1, ChronoUnit.DAYS);
}
return (dateTimeFormatter.format(zoned) + "-" + dateTimeFormatter.format(zoned.plusDays(6)));
/*
int weekOfYear = zoned.get(ChronoField.ALIGNED_WEEK_OF_YEAR);
ZonedDateTime sunday = zoned.with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, weekOfYear).with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY));
ZonedDateTime saturday = sunday.with(TemporalAdjusters.next(DayOfWeek.SATURDAY));
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("M'/'d");
return (dateTimeFormatter.format(sunday) + "-" + dateTimeFormatter.format(saturday));
*/
}
return (humanStringFormatter.format(zoned));
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("checkstyle:indentation")
public Instant roundDown(Instant instant)
{
ZonedDateTime zoned = instant.atZone(ValueUtils.getSessionOrInstanceZoneId());
return switch(this)
{
case YEAR -> zoned.with(TemporalAdjusters.firstDayOfYear()).truncatedTo(ChronoUnit.DAYS).toInstant();
case MONTH -> zoned.with(TemporalAdjusters.firstDayOfMonth()).truncatedTo(ChronoUnit.DAYS).toInstant();
case WEEK ->
{
while(zoned.get(ChronoField.DAY_OF_WEEK) != DayOfWeek.SUNDAY.getValue())
{
zoned = zoned.minusDays(1);
}
yield (zoned.truncatedTo(ChronoUnit.DAYS).toInstant());
}
case DAY -> zoned.truncatedTo(ChronoUnit.DAYS).toInstant();
case HOUR -> zoned.truncatedTo(ChronoUnit.HOURS).toInstant();
};
}
/*******************************************************************************
**
*******************************************************************************/
public Instant increment(Instant instant)
{
ZonedDateTime zoned = instant.atZone(ValueUtils.getSessionOrInstanceZoneId());
return (zoned.plus(noOfChronoUnitsToAdd, chronoUnitToAdd).toInstant());
}
}

View File

@ -0,0 +1,241 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.actions.dashboard.widgets;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.function.BiFunction;
import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/*******************************************************************************
** Unit test for DateTimeGroupBy
*******************************************************************************/
class DateTimeGroupByTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMillisPer()
{
assertEquals(3_600_000L, DateTimeGroupBy.MillisPer.HOUR);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testRoundDown()
{
assertEquals(Instant.parse("2023-01-01T00:00:00Z"), DateTimeGroupBy.YEAR.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
assertEquals(Instant.parse("2023-03-01T00:00:00Z"), DateTimeGroupBy.MONTH.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
assertEquals(Instant.parse("2023-03-12T00:00:00Z"), DateTimeGroupBy.WEEK.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
assertEquals(Instant.parse("2023-03-13T00:00:00Z"), DateTimeGroupBy.DAY.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
assertEquals(Instant.parse("2023-03-13T14:00:00Z"), DateTimeGroupBy.HOUR.roundDown(Instant.parse("2023-03-13T14:50:00Z")));
QContext.getQInstance().setDefaultTimeZoneId("US/Eastern");
assertEquals(Instant.parse("2023-02-01T05:00:00Z"), DateTimeGroupBy.MONTH.roundDown(Instant.parse("2023-03-01T00:00:00Z")));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testIncrement()
{
assertEquals(Instant.parse("2024-03-01T00:00:00Z"), DateTimeGroupBy.YEAR.increment(Instant.parse("2023-03-01T00:00:00Z")));
assertEquals(Instant.parse("2024-01-01T00:00:00Z"), DateTimeGroupBy.MONTH.increment(Instant.parse("2023-12-01T00:00:00Z")));
assertEquals(Instant.parse("2024-01-01T00:00:00Z"), DateTimeGroupBy.MONTH.increment(Instant.parse("2023-12-01T00:00:00Z")));
assertEquals(Instant.parse("2023-03-08T00:00:00Z"), DateTimeGroupBy.WEEK.increment(Instant.parse("2023-03-01T00:00:00Z")));
assertEquals(Instant.parse("2023-03-02T00:00:00Z"), DateTimeGroupBy.DAY.increment(Instant.parse("2023-03-01T00:00:00Z")));
assertEquals(Instant.parse("2023-03-01T01:00:00Z"), DateTimeGroupBy.HOUR.increment(Instant.parse("2023-03-01T00:00:00Z")));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testSelectFromStartAndEndTimes()
{
///////////////////////////////////////////////////////////////////
// simple wrapper to call the function w/ a specified time range //
///////////////////////////////////////////////////////////////////
BiFunction<Integer, ChronoUnit, DateTimeGroupBy> f = (amountToAdd, unit) ->
{
Instant start = Instant.parse("2021-01-01T00:00:00Z");
Instant end = start.plus(amountToAdd, unit);
return (DateTimeGroupBy.selectFromStartAndEndTimes(start, end));
};
///////////////////////////////////////////////////////////////////
// choose YEAR if the timeframe is any amount larger than a year //
///////////////////////////////////////////////////////////////////
assertEquals(DateTimeGroupBy.YEAR, f.apply(365 * 10, ChronoUnit.DAYS));
assertEquals(DateTimeGroupBy.YEAR, f.apply(365 + 1, ChronoUnit.DAYS));
/////////////////////////////////////////////////////////
// choose month if equal to 1 year, or down to 60 days //
/////////////////////////////////////////////////////////
assertEquals(DateTimeGroupBy.MONTH, f.apply(365, ChronoUnit.DAYS));
assertEquals(DateTimeGroupBy.MONTH, f.apply(61, ChronoUnit.DAYS));
///////////////////////////////////////
// week between 60 days and 35 days //
///////////////////////////////////////
assertEquals(DateTimeGroupBy.WEEK, f.apply(60, ChronoUnit.DAYS));
assertEquals(DateTimeGroupBy.WEEK, f.apply(36, ChronoUnit.DAYS));
//////////////////////////////////////
// day between 35 days and 36 hours //
//////////////////////////////////////
assertEquals(DateTimeGroupBy.DAY, f.apply(35, ChronoUnit.DAYS));
assertEquals(DateTimeGroupBy.DAY, f.apply(37, ChronoUnit.HOURS));
//////////////////////////////////////////
// hour under 36 hours (even negative!) //
//////////////////////////////////////////
assertEquals(DateTimeGroupBy.HOUR, f.apply(35, ChronoUnit.HOURS));
assertEquals(DateTimeGroupBy.HOUR, f.apply(1, ChronoUnit.HOURS));
assertEquals(DateTimeGroupBy.HOUR, f.apply(0, ChronoUnit.HOURS));
assertEquals(DateTimeGroupBy.HOUR, f.apply(-1, ChronoUnit.HOURS));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testGetSqlExpression()
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// note - double %'s on the time format strings here, because this is a java-format string, which will get //
// its '%s' replaced with a column name, and so then those %'s for the date_format need escaped as %%. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'UTC'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'UTC'), '%%Y-%%m')", DateTimeGroupBy.MONTH.getSqlExpression());
/////////////////////////////////////////////////////////////////////////////
// if session has no zone info, but instance does, assert that it is used. //
/////////////////////////////////////////////////////////////////////////////
QContext.getQInstance().setDefaultTimeZoneId("US/Eastern");
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Eastern'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
QContext.getQInstance().setDefaultTimeZoneId("US/Central");
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Central'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
//////////////////////////////////////////////////////////////////
// put a zone offset (but not name) in session - see it be used //
//////////////////////////////////////////////////////////////////
QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES, "-300");
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '-05:00'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES, "-420");
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', '-07:00'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
///////////////////////////////////////////////////
// put a zone (name) in session - see it be used //
///////////////////////////////////////////////////
QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE_OFFSET_MINUTES, null);
QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE, "US/Central");
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Central'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
QContext.getQSession().setValue(QSession.VALUE_KEY_USER_TIMEZONE, "US/Eastern");
assertEquals("DATE_FORMAT(CONVERT_TZ(%s, 'UTC', 'US/Eastern'), '%%Y')", DateTimeGroupBy.YEAR.getSqlExpression());
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMakeSelectedString()
{
assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-12-31T11:59:59Z")));
///////////////////////////////////////////////
// make sure a timezone does what's expected //
///////////////////////////////////////////////
QContext.getQInstance().setDefaultTimeZoneId("US/Central");
assertEquals("2020", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-01-01T06:00:00Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2021-12-31T11:59:59Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeSelectedString(Instant.parse("2022-01-01T03:00:00Z")));
///////////////////////////////////////////////
// reset to UTC - test the other enum values //
///////////////////////////////////////////////
QContext.getQInstance().setDefaultTimeZoneId("UTC");
assertEquals("2021-01", DateTimeGroupBy.MONTH.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("2021W01", DateTimeGroupBy.WEEK.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("2021W01", DateTimeGroupBy.WEEK.makeSelectedString(Instant.parse("2020-12-31T00:00:00Z")));
assertEquals("2021-01-01", DateTimeGroupBy.DAY.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("2021-01-01T00", DateTimeGroupBy.HOUR.makeSelectedString(Instant.parse("2021-01-01T00:00:00Z")));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testMakeHumanString()
{
assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-12-31T11:59:59Z")));
///////////////////////////////////////////////
// make sure a timezone does what's expected //
///////////////////////////////////////////////
QContext.getQInstance().setDefaultTimeZoneId("US/Central");
assertEquals("2020", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-01-01T06:00:00Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2021-12-31T11:59:59Z")));
assertEquals("2021", DateTimeGroupBy.YEAR.makeHumanString(Instant.parse("2022-01-01T03:00:00Z")));
///////////////////////////////////////////////
// reset to UTC - test the other enum values //
///////////////////////////////////////////////
QContext.getQInstance().setDefaultTimeZoneId("UTC");
assertEquals("Jan. 2021", DateTimeGroupBy.MONTH.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("12/27-1/2", DateTimeGroupBy.WEEK.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("12/27-1/2", DateTimeGroupBy.WEEK.makeHumanString(Instant.parse("2020-12-31T00:00:00Z")));
assertEquals("Fri. 1/1", DateTimeGroupBy.DAY.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
assertEquals("12 AM", DateTimeGroupBy.HOUR.makeHumanString(Instant.parse("2021-01-01T00:00:00Z")));
}
}