Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57aa866144 | |||
| ac96c6451e | |||
| 39664957fe |
@@ -47,7 +47,7 @@ public class AthenaJsonHandler {
|
|||||||
resultConfiguration = mapper.treeToValue(request.get("ResultConfiguration"), ResultConfiguration.class);
|
resultConfiguration = mapper.treeToValue(request.get("ResultConfiguration"), ResultConfiguration.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
String id = athenaService.startQueryExecution(query, workGroup, context, resultConfiguration);
|
String id = athenaService.startQueryExecution(query, workGroup, context, resultConfiguration, region);
|
||||||
yield Response.ok(Map.of("QueryExecutionId", id)).build();
|
yield Response.ok(Map.of("QueryExecutionId", id)).build();
|
||||||
}
|
}
|
||||||
case "GetQueryExecution" -> {
|
case "GetQueryExecution" -> {
|
||||||
@@ -62,7 +62,7 @@ public class AthenaJsonHandler {
|
|||||||
}
|
}
|
||||||
case "ListQueryExecutions" -> {
|
case "ListQueryExecutions" -> {
|
||||||
yield Response.ok(Map.of("QueryExecutionIds",
|
yield Response.ok(Map.of("QueryExecutionIds",
|
||||||
athenaService.listQueryExecutions().stream()
|
athenaService.listQueryExecutions(request.has("WorkGroup") ? request.get("WorkGroup").asText() : null).stream()
|
||||||
.map(QueryExecution::getQueryExecutionId).toList())).build();
|
.map(QueryExecution::getQueryExecutionId).toList())).build();
|
||||||
}
|
}
|
||||||
case "StopQueryExecution" -> {
|
case "StopQueryExecution" -> {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ public class AthenaService {
|
|||||||
public static final String DEFAULT_CATALOG = "AwsDataCatalog";
|
public static final String DEFAULT_CATALOG = "AwsDataCatalog";
|
||||||
private static final String DEFAULT_OUTPUT_BUCKET = "floci-athena-results";
|
private static final String DEFAULT_OUTPUT_BUCKET = "floci-athena-results";
|
||||||
private static final String DEFAULT_WORKGROUP = "primary";
|
private static final String DEFAULT_WORKGROUP = "primary";
|
||||||
|
private static final Pattern ENGINE_FAILURE = Pattern.compile("^floci-duck \\w+ returned HTTP \\d+: (\\{.*})$", Pattern.DOTALL);
|
||||||
|
private static final com.fasterxml.jackson.databind.ObjectMapper FAILURE_READER = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
private static final Pattern TRAILING_SEMICOLONS = Pattern.compile("[\\s;]+$");
|
||||||
private static final String DEFAULT_ENGINE_VERSION = "Athena engine version 3";
|
private static final String DEFAULT_ENGINE_VERSION = "Athena engine version 3";
|
||||||
private static final long MIN_BYTES_SCANNED_CUTOFF_PER_QUERY = 10_000_000L;
|
private static final long MIN_BYTES_SCANNED_CUTOFF_PER_QUERY = 10_000_000L;
|
||||||
private static final String WORKGROUP_RESOURCE = "workgroup/";
|
private static final String WORKGROUP_RESOURCE = "workgroup/";
|
||||||
@@ -122,6 +125,20 @@ public class AthenaService {
|
|||||||
String workGroup,
|
String workGroup,
|
||||||
QueryExecutionContext context,
|
QueryExecutionContext context,
|
||||||
ResultConfiguration resultConfiguration) {
|
ResultConfiguration resultConfiguration) {
|
||||||
|
return startQueryExecution(query, workGroup, context, resultConfiguration, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a query in a workgroup. As in AWS, the workgroup's result location applies when the
|
||||||
|
* request names none, and always when the workgroup enforces its configuration; an unknown
|
||||||
|
* workgroup is refused. A null region skips the workgroup lookup.
|
||||||
|
*/
|
||||||
|
public String startQueryExecution(String query,
|
||||||
|
String workGroup,
|
||||||
|
QueryExecutionContext context,
|
||||||
|
ResultConfiguration resultConfiguration,
|
||||||
|
String region) {
|
||||||
|
WorkGroupConfiguration workGroupConfiguration = region == null ? null : workGroupFor(region, workGroup).getConfiguration();
|
||||||
String id = UUID.randomUUID().toString();
|
String id = UUID.randomUUID().toString();
|
||||||
String database = context != null && context.getDatabase() != null ? context.getDatabase() : "default";
|
String database = context != null && context.getDatabase() != null ? context.getDatabase() : "default";
|
||||||
QueryExecutionContext resolvedContext = context != null ? context : new QueryExecutionContext();
|
QueryExecutionContext resolvedContext = context != null ? context : new QueryExecutionContext();
|
||||||
@@ -135,7 +152,7 @@ public class AthenaService {
|
|||||||
// directory prefix — the same key is written and returned to the client.
|
// directory prefix — the same key is written and returned to the client.
|
||||||
// Statements that do not return rows must not be wrapped in a result COPY.
|
// Statements that do not return rows must not be wrapped in a result COPY.
|
||||||
String outputLocation = producesResultRows(query)
|
String outputLocation = producesResultRows(query)
|
||||||
? resolveOutputLocation(resultConfiguration, id)
|
? resolveOutputLocation(resultConfiguration, workGroupConfiguration, id)
|
||||||
: null;
|
: null;
|
||||||
ResultConfiguration resolvedResult = outputLocation != null
|
ResultConfiguration resolvedResult = outputLocation != null
|
||||||
? new ResultConfiguration(outputLocation)
|
? new ResultConfiguration(outputLocation)
|
||||||
@@ -177,7 +194,7 @@ public class AthenaService {
|
|||||||
if (outputLocation != null) {
|
if (outputLocation != null) {
|
||||||
ensureOutputBucket(outputLocation);
|
ensureOutputBucket(outputLocation);
|
||||||
}
|
}
|
||||||
duckClient.execute(query, setupDdl, outputLocation);
|
duckClient.execute(withoutTrailingSemicolons(query), setupDdl, outputLocation);
|
||||||
return null;
|
return null;
|
||||||
}).onSuccess(v -> {
|
}).onSuccess(v -> {
|
||||||
markSucceeded(id, execution);
|
markSucceeded(id, execution);
|
||||||
@@ -214,6 +231,16 @@ public class AthenaService {
|
|||||||
return queryStore.scan(k -> true);
|
return queryStore.scan(k -> true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A workgroup's queries, most recent first, as ListQueryExecutions returns them. */
|
||||||
|
public List<QueryExecution> listQueryExecutions(String workGroup) {
|
||||||
|
String name = workGroup == null || workGroup.isBlank() ? DEFAULT_WORKGROUP : workGroup;
|
||||||
|
return queryStore.scan(k -> true).stream()
|
||||||
|
.filter(execution -> name.equals(execution.getWorkGroup() == null ? DEFAULT_WORKGROUP : execution.getWorkGroup()))
|
||||||
|
.sorted(Comparator.comparing((QueryExecution execution) -> execution.getStatus().getSubmissionDateTime(),
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder())).reversed())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public void stopQueryExecution(String id) {
|
public void stopQueryExecution(String id) {
|
||||||
QueryExecution execution = getQueryExecution(id);
|
QueryExecution execution = getQueryExecution(id);
|
||||||
execution.getStatus().setState(QueryExecutionState.CANCELLED);
|
execution.getStatus().setState(QueryExecutionState.CANCELLED);
|
||||||
@@ -531,6 +558,15 @@ public class AthenaService {
|
|||||||
return properties;
|
return properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Athena accepts one statement with or without a closing semicolon. The engine wraps the
|
||||||
|
* statement in {@code COPY (...) TO ...}, where a semicolon is a syntax error, so it is
|
||||||
|
* dropped before the query is handed over.
|
||||||
|
*/
|
||||||
|
static String withoutTrailingSemicolons(String query) {
|
||||||
|
return query == null ? null : TRAILING_SEMICOLONS.matcher(query).replaceFirst("");
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean producesResultRows(String query) {
|
private static boolean producesResultRows(String query) {
|
||||||
return RESULT_STATEMENT_PATTERN.matcher(statementText(query)).find();
|
return RESULT_STATEMENT_PATTERN.matcher(statementText(query)).find();
|
||||||
}
|
}
|
||||||
@@ -576,17 +612,53 @@ public class AthenaService {
|
|||||||
|
|
||||||
private void markFailed(String id, QueryExecution execution, Throwable failure) {
|
private void markFailed(String id, QueryExecution execution, Throwable failure) {
|
||||||
execution.getStatus().setState(QueryExecutionState.FAILED);
|
execution.getStatus().setState(QueryExecutionState.FAILED);
|
||||||
execution.getStatus().setStateChangeReason(failure.getMessage());
|
execution.getStatus().setStateChangeReason(failureReason(failure.getMessage()));
|
||||||
queryStore.put(id, execution);
|
queryStore.put(id, execution);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine's own explanation of a failed query, as Athena reports it: without the HTTP
|
||||||
|
* envelope of the engine call, and without the echoed statement, which shows the
|
||||||
|
* internal {@code COPY ... TO} wrapper rather than the query that was sent.
|
||||||
|
*/
|
||||||
|
static String failureReason(String message) {
|
||||||
|
if (message == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Matcher envelope = ENGINE_FAILURE.matcher(message);
|
||||||
|
if (!envelope.matches()) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String engineMessage = FAILURE_READER.readTree(envelope.group(1)).path("message").asText("");
|
||||||
|
if (engineMessage.isBlank()) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
int echo = engineMessage.indexOf("\n\nLINE ");
|
||||||
|
return (echo >= 0 ? engineMessage.substring(0, echo) : engineMessage).strip();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private record CreateDatabaseDdl(String name, boolean ifNotExists, String comment,
|
private record CreateDatabaseDdl(String name, boolean ifNotExists, String comment,
|
||||||
String location, Map<String, String> properties) {
|
String location, Map<String, String> properties) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveOutputLocation(ResultConfiguration rc, String queryId) {
|
private String resolveOutputLocation(ResultConfiguration rc, WorkGroupConfiguration workGroupConfiguration, String queryId) {
|
||||||
String base = (rc != null && rc.getOutputLocation() != null && !rc.getOutputLocation().isBlank())
|
String requested = rc != null && rc.getOutputLocation() != null && !rc.getOutputLocation().isBlank()
|
||||||
? rc.getOutputLocation()
|
? rc.getOutputLocation()
|
||||||
|
: null;
|
||||||
|
String workGroupLocation = workGroupConfiguration != null
|
||||||
|
&& workGroupConfiguration.getResultConfiguration() != null
|
||||||
|
&& workGroupConfiguration.getResultConfiguration().getOutputLocation() != null
|
||||||
|
&& !workGroupConfiguration.getResultConfiguration().getOutputLocation().isBlank()
|
||||||
|
? workGroupConfiguration.getResultConfiguration().getOutputLocation()
|
||||||
|
: null;
|
||||||
|
boolean enforced = workGroupConfiguration != null && Boolean.TRUE.equals(workGroupConfiguration.getEnforceWorkGroupConfiguration());
|
||||||
|
String base = enforced && workGroupLocation != null ? workGroupLocation
|
||||||
|
: requested != null ? requested
|
||||||
|
: workGroupLocation != null ? workGroupLocation
|
||||||
: "s3://" + DEFAULT_OUTPUT_BUCKET + "/results/";
|
: "s3://" + DEFAULT_OUTPUT_BUCKET + "/results/";
|
||||||
return base.endsWith("/") ? base + queryId + ".csv" : base + "/" + queryId + ".csv";
|
return base.endsWith("/") ? base + queryId + ".csv" : base + "/" + queryId + ".csv";
|
||||||
}
|
}
|
||||||
@@ -692,6 +764,12 @@ public class AthenaService {
|
|||||||
return engineVersion;
|
return engineVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WorkGroup workGroupFor(String region, String name) {
|
||||||
|
return name == null || name.isBlank() || DEFAULT_WORKGROUP.equals(name)
|
||||||
|
? primaryWorkGroup(region)
|
||||||
|
: requireWorkGroup(region, name);
|
||||||
|
}
|
||||||
|
|
||||||
private WorkGroup primaryWorkGroup(String region) {
|
private WorkGroup primaryWorkGroup(String region) {
|
||||||
return workGroupStore.get(workGroupKey(region, DEFAULT_WORKGROUP)).orElseGet(() -> {
|
return workGroupStore.get(workGroupKey(region, DEFAULT_WORKGROUP)).orElseGet(() -> {
|
||||||
WorkGroup workGroup = new WorkGroup();
|
WorkGroup workGroup = new WorkGroup();
|
||||||
|
|||||||
@@ -243,9 +243,6 @@ public class GlueService {
|
|||||||
}
|
}
|
||||||
database.setName(databaseName);
|
database.setName(databaseName);
|
||||||
database.setCatalogId(regionResolver.getAccountId());
|
database.setCatalogId(regionResolver.getAccountId());
|
||||||
if (database.getCreateTime() == null) {
|
|
||||||
database.setCreateTime(Instant.now());
|
|
||||||
}
|
|
||||||
databaseStore.put(databaseName, database);
|
databaseStore.put(databaseName, database);
|
||||||
if (tags != null && !tags.isEmpty()) {
|
if (tags != null && !tags.isEmpty()) {
|
||||||
resourceGroupsTaggingService.tagResources(List.of(databaseArn(region, databaseName)), tags, region);
|
resourceGroupsTaggingService.tagResources(List.of(databaseArn(region, databaseName)), tags, region);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package io.github.hectorvent.floci.services.glue.model;
|
|||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -48,12 +47,10 @@ public class Connection {
|
|||||||
|
|
||||||
@JsonProperty("CreationTime")
|
@JsonProperty("CreationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant creationTime;
|
private Instant creationTime;
|
||||||
|
|
||||||
@JsonProperty("LastUpdatedTime")
|
@JsonProperty("LastUpdatedTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant lastUpdatedTime;
|
private Instant lastUpdatedTime;
|
||||||
|
|
||||||
@JsonProperty("LastUpdatedBy")
|
@JsonProperty("LastUpdatedBy")
|
||||||
@@ -67,7 +64,6 @@ public class Connection {
|
|||||||
|
|
||||||
@JsonProperty("LastConnectionValidationTime")
|
@JsonProperty("LastConnectionValidationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant lastConnectionValidationTime;
|
private Instant lastConnectionValidationTime;
|
||||||
|
|
||||||
@JsonProperty("AuthenticationConfiguration")
|
@JsonProperty("AuthenticationConfiguration")
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class Crawler {
|
public class Crawler {
|
||||||
@JsonProperty("Classifiers")
|
@JsonProperty("Classifiers")
|
||||||
private List<String> classifiers;
|
private List<String> classifiers;
|
||||||
@@ -25,7 +22,6 @@ public class Crawler {
|
|||||||
|
|
||||||
@JsonProperty("CreationTime")
|
@JsonProperty("CreationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant creationTime;
|
private Instant creationTime;
|
||||||
|
|
||||||
@JsonProperty("DatabaseName")
|
@JsonProperty("DatabaseName")
|
||||||
@@ -42,7 +38,6 @@ public class Crawler {
|
|||||||
|
|
||||||
@JsonProperty("LastUpdated")
|
@JsonProperty("LastUpdated")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant lastUpdated;
|
private Instant lastUpdated;
|
||||||
|
|
||||||
@JsonProperty("LineageConfiguration")
|
@JsonProperty("LineageConfiguration")
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class Database {
|
public class Database {
|
||||||
@JsonProperty("Name")
|
@JsonProperty("Name")
|
||||||
private String name;
|
private String name;
|
||||||
@@ -21,7 +18,6 @@ public class Database {
|
|||||||
private Map<String, String> parameters;
|
private Map<String, String> parameters;
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
@JsonProperty("CatalogId")
|
@JsonProperty("CatalogId")
|
||||||
private String catalogId;
|
private String catalogId;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package io.github.hectorvent.floci.services.glue.model;
|
|||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -23,12 +22,10 @@ public class GluePolicy {
|
|||||||
|
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
|
|
||||||
@JsonProperty("UpdateTime")
|
@JsonProperty("UpdateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant updateTime;
|
private Instant updateTime;
|
||||||
|
|
||||||
public String getPolicyInJson() { return policyInJson; }
|
public String getPolicyInJson() { return policyInJson; }
|
||||||
|
|||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonGenerator;
|
|
||||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
|
||||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a Glue timestamp as AWS does: epoch seconds to the millisecond, in the shortest
|
|
||||||
* form ({@code 1792964720.27}, not {@code 1792964720.270000000}). The AWS SDK for
|
|
||||||
* JavaScript v3 keeps any number whose text differs from its shortest form as a
|
|
||||||
* BigDecimal, and then refuses it as a timestamp.
|
|
||||||
*/
|
|
||||||
@RegisterForReflection
|
|
||||||
public class GlueTimestampSerializer extends JsonSerializer<Instant> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void serialize(Instant value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
|
||||||
gen.writeNumber(epochSeconds(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
static String epochSeconds(Instant value) {
|
|
||||||
return BigDecimal.valueOf(value.toEpochMilli(), 3).stripTrailingZeros().toPlainString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
@@ -11,7 +9,6 @@ import java.time.Instant;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class Job {
|
public class Job {
|
||||||
@JsonProperty("AllocatedCapacity")
|
@JsonProperty("AllocatedCapacity")
|
||||||
private Integer allocatedCapacity;
|
private Integer allocatedCapacity;
|
||||||
@@ -27,7 +24,6 @@ public class Job {
|
|||||||
|
|
||||||
@JsonProperty("CreatedOn")
|
@JsonProperty("CreatedOn")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant createdOn;
|
private Instant createdOn;
|
||||||
|
|
||||||
@JsonProperty("DefaultArguments")
|
@JsonProperty("DefaultArguments")
|
||||||
@@ -53,7 +49,6 @@ public class Job {
|
|||||||
|
|
||||||
@JsonProperty("LastModifiedOn")
|
@JsonProperty("LastModifiedOn")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant lastModifiedOn;
|
private Instant lastModifiedOn;
|
||||||
|
|
||||||
@JsonProperty("LogUri")
|
@JsonProperty("LogUri")
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class LastCrawlInfo {
|
public class LastCrawlInfo {
|
||||||
@JsonProperty("ErrorMessage")
|
@JsonProperty("ErrorMessage")
|
||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
@@ -24,7 +21,6 @@ public class LastCrawlInfo {
|
|||||||
|
|
||||||
@JsonProperty("StartTime")
|
@JsonProperty("StartTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant startTime;
|
private Instant startTime;
|
||||||
|
|
||||||
@JsonProperty("Status")
|
@JsonProperty("Status")
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class Partition {
|
public class Partition {
|
||||||
@JsonProperty("Values")
|
@JsonProperty("Values")
|
||||||
private List<String> values;
|
private List<String> values;
|
||||||
@@ -20,11 +17,9 @@ public class Partition {
|
|||||||
private String tableName;
|
private String tableName;
|
||||||
@JsonProperty("CreationTime")
|
@JsonProperty("CreationTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant creationTime;
|
private Instant creationTime;
|
||||||
@JsonProperty("LastAccessTime")
|
@JsonProperty("LastAccessTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant lastAccessTime;
|
private Instant lastAccessTime;
|
||||||
@JsonProperty("StorageDescriptor")
|
@JsonProperty("StorageDescriptor")
|
||||||
private StorageDescriptor storageDescriptor;
|
private StorageDescriptor storageDescriptor;
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class Table {
|
public class Table {
|
||||||
@JsonProperty("Name")
|
@JsonProperty("Name")
|
||||||
private String name;
|
private String name;
|
||||||
@@ -22,15 +19,12 @@ public class Table {
|
|||||||
private String owner;
|
private String owner;
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
@JsonProperty("UpdateTime")
|
@JsonProperty("UpdateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant updateTime;
|
private Instant updateTime;
|
||||||
@JsonProperty("LastAccessTime")
|
@JsonProperty("LastAccessTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant lastAccessTime;
|
private Instant lastAccessTime;
|
||||||
@JsonProperty("PartitionKeys")
|
@JsonProperty("PartitionKeys")
|
||||||
private List<Column> partitionKeys;
|
private List<Column> partitionKeys;
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
package io.github.hectorvent.floci.services.glue.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
public class UserDefinedFunction {
|
public class UserDefinedFunction {
|
||||||
@JsonProperty("FunctionName")
|
@JsonProperty("FunctionName")
|
||||||
private String functionName;
|
private String functionName;
|
||||||
@@ -26,7 +23,6 @@ public class UserDefinedFunction {
|
|||||||
private String ownerType;
|
private String ownerType;
|
||||||
@JsonProperty("CreateTime")
|
@JsonProperty("CreateTime")
|
||||||
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
|
||||||
@JsonSerialize(using = GlueTimestampSerializer.class)
|
|
||||||
private Instant createTime;
|
private Instant createTime;
|
||||||
@JsonProperty("ResourceUris")
|
@JsonProperty("ResourceUris")
|
||||||
private List<ResourceUri> resourceUris;
|
private List<ResourceUri> resourceUris;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package io.github.hectorvent.floci.services.athena;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class AthenaQueryTextTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dropsTheClosingSemicolonsAthenaAccepts() {
|
||||||
|
assertEquals("SELECT * FROM orders LIMIT 10", AthenaService.withoutTrailingSemicolons("SELECT * FROM orders LIMIT 10;"));
|
||||||
|
assertEquals("SELECT 1", AthenaService.withoutTrailingSemicolons("SELECT 1 ; ;\n"));
|
||||||
|
assertEquals("SELECT ';' AS s", AthenaService.withoutTrailingSemicolons("SELECT ';' AS s"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsTheEngineErrorWithoutItsEnvelopeOrTheWrappedStatement() {
|
||||||
|
String engine = "floci-duck execute returned HTTP 500: {\"status\":\"error\",\"message\":\"Catalog Error: Table with name nope does not exist!\\nDid you mean \\\"orders\\\"?\\n\\nLINE 1: COPY (SELECT * FROM nope) TO 's3://results/q.csv'\"}";
|
||||||
|
assertEquals("Catalog Error: Table with name nope does not exist!\nDid you mean \"orders\"?", AthenaService.failureReason(engine));
|
||||||
|
assertEquals("Failed to call floci-duck execute: timeout", AthenaService.failureReason("Failed to call floci-duck execute: timeout"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package io.github.hectorvent.floci.services.athena;
|
||||||
|
|
||||||
|
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
import static org.hamcrest.Matchers.hasItem;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
|
import static org.hamcrest.Matchers.startsWith;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/** Queries belong to their workgroup: they use its result location and are listed under it. */
|
||||||
|
@QuarkusTest
|
||||||
|
class AthenaWorkGroupQueriesIntegrationTest {
|
||||||
|
|
||||||
|
private static final String CONTENT_TYPE = "application/x-amz-json-1.1";
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void configureRestAssured() {
|
||||||
|
RestAssuredJsonUtils.configureAwsContentTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static io.restassured.response.ValidatableResponse athena(String action, String body) {
|
||||||
|
return given()
|
||||||
|
.header("X-Amz-Target", "AmazonAthena." + action)
|
||||||
|
.contentType(CONTENT_TYPE)
|
||||||
|
.body(body)
|
||||||
|
.when().post("/")
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String workGroup(String location, boolean enforce) {
|
||||||
|
String name = "wg-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
athena("CreateWorkGroup", """
|
||||||
|
{"Name": "%s", "Configuration": {"ResultConfiguration": {"OutputLocation": "%s"}, "EnforceWorkGroupConfiguration": %s}}
|
||||||
|
""".formatted(name, location, enforce)).statusCode(200);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String start(String workGroup, String outputLocation) {
|
||||||
|
String result = outputLocation == null ? "" : ", \"ResultConfiguration\": {\"OutputLocation\": \"" + outputLocation + "\"}";
|
||||||
|
return athena("StartQueryExecution", "{\"QueryString\": \"SELECT 1\", \"WorkGroup\": \"" + workGroup + "\"" + result + "}")
|
||||||
|
.statusCode(200)
|
||||||
|
.extract().jsonPath().getString("QueryExecutionId");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String outputLocation(String queryId) {
|
||||||
|
return athena("GetQueryExecution", "{\"QueryExecutionId\": \"" + queryId + "\"}")
|
||||||
|
.statusCode(200)
|
||||||
|
.body("QueryExecution.WorkGroup", not(equalTo(null)))
|
||||||
|
.extract().jsonPath().getString("QueryExecution.ResultConfiguration.OutputLocation");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usesTheWorkGroupResultLocationWhenTheQueryNamesNone() {
|
||||||
|
String name = workGroup("s3://team-results/athena/", false);
|
||||||
|
String queryId = start(name, null);
|
||||||
|
|
||||||
|
assertEquals("s3://team-results/athena/" + queryId + ".csv", outputLocation(queryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aRequestedLocationWinsUnlessTheWorkGroupEnforcesItsOwn() {
|
||||||
|
String open = workGroup("s3://team-results/open/", false);
|
||||||
|
String openQuery = start(open, "s3://mine/results/");
|
||||||
|
assertEquals("s3://mine/results/" + openQuery + ".csv", outputLocation(openQuery));
|
||||||
|
|
||||||
|
String enforced = workGroup("s3://team-results/enforced/", true);
|
||||||
|
String enforcedQuery = start(enforced, "s3://mine/results/");
|
||||||
|
assertEquals("s3://team-results/enforced/" + enforcedQuery + ".csv", outputLocation(enforcedQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesAWorkGroupThatDoesNotExist() {
|
||||||
|
athena("StartQueryExecution", "{\"QueryString\": \"SELECT 1\", \"WorkGroup\": \"no-such-group\"}")
|
||||||
|
.statusCode(400)
|
||||||
|
.body("__type", equalTo("InvalidRequestException"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsOnlyTheWorkGroupQueriesNewestFirst() {
|
||||||
|
String name = workGroup("s3://team-results/listed/", false);
|
||||||
|
String first = start(name, null);
|
||||||
|
String second = start(name, null);
|
||||||
|
String elsewhere = start("primary", null);
|
||||||
|
|
||||||
|
List<String> listed = athena("ListQueryExecutions", "{\"WorkGroup\": \"" + name + "\"}")
|
||||||
|
.statusCode(200)
|
||||||
|
.extract().jsonPath().getList("QueryExecutionIds", String.class);
|
||||||
|
assertEquals(List.of(second, first), listed);
|
||||||
|
|
||||||
|
athena("ListQueryExecutions", "{}")
|
||||||
|
.statusCode(200)
|
||||||
|
.body("QueryExecutionIds", hasItem(elsewhere))
|
||||||
|
.body("QueryExecutionIds", not(hasItem(first)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thePrimaryWorkGroupKeepsItsDefaultLocation() {
|
||||||
|
String queryId = start("primary", null);
|
||||||
|
org.hamcrest.MatcherAssert.assertThat(outputLocation(queryId), startsWith("s3://floci-athena-results/results/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
package io.github.hectorvent.floci.services.glue;
|
|
||||||
|
|
||||||
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
|
|
||||||
import io.quarkus.test.junit.QuarkusTest;
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static io.restassured.RestAssured.given;
|
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
|
||||||
import static org.hamcrest.Matchers.hasKey;
|
|
||||||
import static org.hamcrest.Matchers.not;
|
|
||||||
import static org.hamcrest.Matchers.notNullValue;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AWS leaves unset fields out of Glue responses and reports times in milliseconds.
|
|
||||||
* A null or nanosecond timestamp instead breaks the AWS SDK for JavaScript v3,
|
|
||||||
* which refuses to read either.
|
|
||||||
*/
|
|
||||||
@QuarkusTest
|
|
||||||
class GlueOmitsNullFieldsIntegrationTest {
|
|
||||||
|
|
||||||
private static final String CONTENT_TYPE = "application/x-amz-json-1.1";
|
|
||||||
private static final String DATABASE = "nulls_" + UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void configureRestAssured() {
|
|
||||||
RestAssuredJsonUtils.configureAwsContentTypes();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static io.restassured.response.ValidatableResponse glue(String action, String body) {
|
|
||||||
return given().contentType(CONTENT_TYPE)
|
|
||||||
.header("X-Amz-Target", "AWSGlue." + action)
|
|
||||||
.body(body)
|
|
||||||
.when().post("/")
|
|
||||||
.then().statusCode(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void aNewTableHasOnlyAMillisecondCreateTime() {
|
|
||||||
glue("CreateDatabase", "{\"DatabaseInput\": {\"Name\": \"" + DATABASE + "\"}}");
|
|
||||||
glue("CreateTable", """
|
|
||||||
{
|
|
||||||
"DatabaseName": "%s",
|
|
||||||
"TableInput": {
|
|
||||||
"Name": "orders",
|
|
||||||
"StorageDescriptor": {"Columns": [{"Name": "id", "Type": "int"}], "Location": "s3://data/orders/"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".formatted(DATABASE));
|
|
||||||
|
|
||||||
String tables = glue("GetTables", "{\"DatabaseName\": \"" + DATABASE + "\"}")
|
|
||||||
.body("TableList[0].Name", equalTo("orders"))
|
|
||||||
.body("TableList[0].CreateTime", notNullValue())
|
|
||||||
.body("TableList[0]", not(hasKey("UpdateTime")))
|
|
||||||
.body("TableList[0]", not(hasKey("LastAccessTime")))
|
|
||||||
.extract().asString();
|
|
||||||
// Milliseconds in their shortest form, as AWS sends them: no trailing zeros.
|
|
||||||
assertTrue(tables.matches("(?s).*\"CreateTime\":\\d+(\\.\\d{0,2}[1-9])?[,}].*"), tables);
|
|
||||||
|
|
||||||
glue("GetDatabase", "{\"Name\": \"" + DATABASE + "\"}")
|
|
||||||
.body("Database", not(hasKey("Description")))
|
|
||||||
.body("Database.CreateTime", notNullValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
package io.github.hectorvent.floci.services.glue.model;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
|
|
||||||
class GlueTimestampSerializerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void writesMillisecondsInTheirShortestForm() {
|
|
||||||
assertEquals("1792964720.273", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720, 273_456_789)));
|
|
||||||
assertEquals("1792964720.27", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720, 270_000_000)));
|
|
||||||
assertEquals("1792964720", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user