Skip to content

Commit

Permalink
Merge pull request #187 from SpineEventEngine/customizable-column-map…
Browse files Browse the repository at this point in the history
…ping

Allow to override the default mappings provided by `DsColumnMapping`
  • Loading branch information
armiol authored Sep 27, 2023
2 parents f346dbb + 041129f commit 2563913
Show file tree
Hide file tree
Showing 11 changed files with 361 additions and 28 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ Gradle:

dependencies {
// Datastore Storage support library.
implementation("io.spine.gcloud:spine-datastore:1.9.0")
implementation("io.spine.gcloud:spine-datastore:1.9.1")

// Pub/Sub messaging support library.
implementation("io.spine.gcloud:spine-pubsub:1.9.0")
implementation("io.spine.gcloud:spine-pubsub:1.9.1")

// Stackdriver Trace support library.
implementation("io.spine.gcloud:spine-stackdriver-trace:1.9.0")
implementation("io.spine.gcloud:spine-stackdriver-trace:1.9.1")

// Datastore-related test utilities (if needed).
testImplementation("io.spine.gcloud:testutil-gcloud:1.9.0")
testImplementation("io.spine.gcloud:testutil-gcloud:1.9.1")
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@
import com.google.protobuf.ByteString;
import com.google.protobuf.Message;
import com.google.protobuf.Timestamp;
import io.spine.annotation.SPI;
import io.spine.core.Version;
import io.spine.server.storage.AbstractColumnMapping;
import io.spine.server.storage.ColumnTypeMapping;
import io.spine.string.Stringifiers;

import java.util.HashMap;
import java.util.Map;

import static com.google.cloud.Timestamp.ofTimeSecondsAndNanos;

/**
Expand All @@ -56,12 +60,51 @@
*/
public class DsColumnMapping extends AbstractColumnMapping<Value<?>> {

private static final Map<Class<?>, ColumnTypeMapping<?, ? extends Value<?>>> defaults
= ImmutableMap.of(Timestamp.class, ofTimestamp(),
Version.class, ofVersion());

/**
* {@inheritDoc}
*
* <p>Merges the default column mapping rules with those provided by SPI users.
* In case there are duplicate mappings for some column type, the value provided
* by SPI users is used.
*
* @apiNote This method is made {@code final}, as it is designed
* to use {@code ImmutableMap.Builder}, which does not allow to override values.
* Therefore, it is not possible for SPI users to provide their own mapping rules
* for types such as {@code Timestamp}, for which this class already has
* a default mapping. SPI users should override
* {@link #customMapping() DsColumnMapping.customMapping()} instead.
*/
@Override
protected void
protected final void
setupCustomMapping(
ImmutableMap.Builder<Class<?>, ColumnTypeMapping<?, ? extends Value<?>>> builder) {
builder.put(Timestamp.class, ofTimestamp());
builder.put(Version.class, ofVersion());
Map<Class<?>, ColumnTypeMapping<?, ? extends Value<?>>> merged = new HashMap<>();
var custom = customMapping();
merged.putAll(defaults);
merged.putAll(custom);
builder.putAll(merged);
}

/**
* Returns the custom column mapping rules.
*
* <p>This method is designed for SPI users in order to be able to re-define
* and-or append their custom mapping. As by default, {@code DsColumnMapping}
* provides rules for {@link Timestamp} and {@link Version}, SPI users may
* choose to either override these defaults by returning their own mapping for these types,
* or supply even more mapping rules.
*
* <p>By default, this method returns an empty map.
*
* @return custom column mappings, per Java class of column
*/
@SPI
protected ImmutableMap<Class<?>, ColumnTypeMapping<?, ? extends Value<?>>> customMapping() {
return ImmutableMap.of();
}

@Override
Expand Down Expand Up @@ -120,16 +163,26 @@ protected ColumnTypeMapping<Message, StringValue> ofMessage() {
return o -> NullValue.of();
}

@SuppressWarnings({"ProtoTimestampGetSecondsGetNano", "UnnecessaryLambda"})
// This behavior is intended.
private static ColumnTypeMapping<Timestamp, TimestampValue> ofTimestamp() {
/**
* Returns the default mapping from {@link Timestamp} to {@link TimestampValue}.
*/
@SuppressWarnings({
"ProtoTimestampGetSecondsGetNano" /* In order to create exact value. */,
"UnnecessaryLambda" /* For brevity.*/,
"WeakerAccess" /* To allow access for SPI users. */})
protected static ColumnTypeMapping<Timestamp, TimestampValue> ofTimestamp() {
return timestamp -> TimestampValue.of(
ofTimeSecondsAndNanos(timestamp.getSeconds(), timestamp.getNanos())
);
}

@SuppressWarnings("UnnecessaryLambda")
private static ColumnTypeMapping<Version, LongValue> ofVersion() {
/**
* Returns the default mapping from {@link Version} to {@link LongValue}.
*/
@SuppressWarnings({
"UnnecessaryLambda" /* For brevity.*/,
"WeakerAccess" /* To allow access for SPI users. */})
protected static ColumnTypeMapping<Version, LongValue> ofVersion() {
return version -> LongValue.of(version.getNumber());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.server.storage.datastore.tenant.given;
package io.spine.server.storage.datastore.given;

import io.spine.server.projection.Projection;
import io.spine.test.datastore.College;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright 2023, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.server.storage.datastore.record;

import io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.CustomMapping;
import io.spine.testing.server.storage.datastore.TestDatastoreStorageFactory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static com.google.common.truth.Truth.assertThat;
import static io.spine.server.storage.datastore.given.TestEnvironment.singleTenantSpec;
import static io.spine.server.storage.datastore.record.RecordId.ofEntityId;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.COLLEGE_CLS;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.COLLEGE_KIND;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.clearAdmission;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.futureFromNow;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.newCollege;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.newId;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.someVersion;
import static io.spine.server.storage.datastore.record.given.DsEntityColumnsTestEnv.writeAndReadDeadline;
import static io.spine.testing.server.storage.datastore.TestDatastoreStorageFactory.local;

@DisplayName("When dealing with `Entity` columns, `DsEntityRecordStorage` should")
final class DsEntityColumnsTest {

private static final TestDatastoreStorageFactory datastoreFactory = local(new CustomMapping());

@Test
@DisplayName("allow clearing the column values " +
"if the column mapping used returns Datastore-specific `null` " +
"for their values")
void clearTimestampColumns() {
var spec = singleTenantSpec();
var storage = datastoreFactory.createEntityRecordStorage(spec, COLLEGE_CLS);
var datastore = datastoreFactory.newDatastoreWrapper(false);

var id = newId();
var key = datastore.keyFor(COLLEGE_KIND, ofEntityId(id));
var version = someVersion();

var admissionDeadline = futureFromNow();
var college = newCollege(id, admissionDeadline);

var storedDeadline = writeAndReadDeadline(college, version, storage, datastore, key);
assertThat(storedDeadline).isNotNull();

var collegeNoAdmission = clearAdmission(college);
var presumablyEmptyDeadline =
writeAndReadDeadline(collegeNoAdmission, version, storage, datastore, key);
assertThat(presumablyEmptyDeadline)
.isNull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* Copyright 2023, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Redistribution and use in source and/or binary forms, with or without
* modification, must retain the above copyright notice and the following
* disclaimer.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package io.spine.server.storage.datastore.record.given;

import com.google.cloud.datastore.Entity;
import com.google.cloud.datastore.Key;
import com.google.cloud.datastore.NullValue;
import com.google.cloud.datastore.TimestampValue;
import com.google.cloud.datastore.Value;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.protobuf.Any;
import com.google.protobuf.Timestamp;
import com.google.protobuf.util.Durations;
import com.google.protobuf.util.Timestamps;
import io.spine.base.Identifier;
import io.spine.base.Time;
import io.spine.core.Version;
import io.spine.core.Versions;
import io.spine.server.entity.EntityRecord;
import io.spine.server.entity.storage.EntityRecordStorage;
import io.spine.server.entity.storage.EntityRecordWithColumns;
import io.spine.server.storage.ColumnTypeMapping;
import io.spine.server.storage.datastore.DatastoreWrapper;
import io.spine.server.storage.datastore.Kind;
import io.spine.server.storage.datastore.config.DsColumnMapping;
import io.spine.server.storage.datastore.given.CollegeProjection;
import io.spine.test.datastore.College;
import io.spine.test.datastore.CollegeId;
import io.spine.type.TypeUrl;

import static com.google.cloud.Timestamp.ofTimeSecondsAndNanos;
import static com.google.common.base.Preconditions.checkArgument;
import static io.spine.base.Identifier.newUuid;
import static io.spine.protobuf.AnyPacker.pack;

/**
* Test environment
* for {@link io.spine.server.storage.datastore.record.DsEntityColumnsTest DsEntityColumnsTest}.
*/
public final class DsEntityColumnsTestEnv {

public static final Class<CollegeProjection> COLLEGE_CLS = CollegeProjection.class;
public static final Kind COLLEGE_KIND = Kind.of(TypeUrl.from(College.getDescriptor()));

/**
* Prevents this test environment from instantiation.
*/
private DsEntityColumnsTestEnv() {
}

public static com.google.cloud.Timestamp
writeAndReadDeadline(College college,
Version version,
EntityRecordStorage<CollegeId, College> storage,
DatastoreWrapper datastore,
Key key) {
var record = toEntityRecord(college, version);
var recordWithCols = EntityRecordWithColumns.create(record, COLLEGE_CLS);
storage.write(recordWithCols);
var response = datastore.read(key);
checkArgument(response.isPresent());
var storedDeadline = readAdmissionDeadline(response.get());
return storedDeadline;
}

public static College newCollege(CollegeId id, Timestamp admissionDeadline) {
var college =
College.newBuilder()
.setId(id)
.setName("Alma")
.setStudentCount(42)
.setAdmissionDeadline(admissionDeadline)
.setPassingGrade(4.2)
.setStateSponsored(false)
.setCreated(Time.currentTime())
.addAllSubjects(
ImmutableList.of("English Literature", "Engineering", "Psychology"))
.build();
return college;
}

private static EntityRecord toEntityRecord(College college, Version version) {
var packedId = Identifier.pack(college.getId());
var recordNoAdmission = EntityRecord
.newBuilder()
.setEntityId(packedId)
.setState(pack(college))
.setVersion(version)
.build();
return recordNoAdmission;
}

public static College clearAdmission(College college) {
return college.toBuilder()
.clearAdmissionDeadline()
.build();
}

public static Version someVersion() {
return Versions.newVersion(42, Time.currentTime());
}

public static Timestamp futureFromNow() {
return Timestamps.add(Time.currentTime(), Durations.fromDays(100));
}

public static CollegeId newId() {
return CollegeId.newBuilder()
.setValue(newUuid())
.build();
}

private static com.google.cloud.Timestamp readAdmissionDeadline(Entity response) {
var storedTimestamp = response.getTimestamp(
College.Column.admissionDeadline()
.name()
.value());
return storedTimestamp;
}

/**
* A mapping similar to the default one,
* but telling to store {@link Timestamp}s as {@code null}s.
*/
public static final class CustomMapping extends DsColumnMapping {

@Override
protected ImmutableMap<Class<?>, ColumnTypeMapping<?, ? extends Value<?>>> customMapping() {
return ImmutableMap.of(Timestamp.class, ofNullableTimestamp());
}

@SuppressWarnings("UnnecessaryLambda" /* For brevity */)
private static ColumnTypeMapping<Timestamp, Value<?>> ofNullableTimestamp() {
return timestamp -> {
if (timestamp.equals(Timestamp.getDefaultInstance())) {
return NullValue.of();
}
return TimestampValue.of(
ofTimeSecondsAndNanos(timestamp.getSeconds(), timestamp.getNanos())
);
};
}
}
}
Loading

0 comments on commit 2563913

Please sign in to comment.