You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by di...@apache.org on 2026/10/19 18:54:48 UTC
(polaris) branch main updated: Persistence: DynamoDB (atomic) single-table data model (#5707)
This is an automated email from the ASF dual-hosted git repository.
dimas-b pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new 87adc3c0b2 Persistence: DynamoDB (atomic) single-table data model (#5707)
87adc3c0b2 is described below
commit 87adc3c0b2d174d8826f0d3b5d2f3b4edac31ac7
Author: Yuewei Zhou <91...@users.noreply.github.com>
AuthorDate: Mon Oct 19 11:54:42 2026 -0700
Persistence: DynamoDB (atomic) single-table data model (#5707)
Adds the DynamoDB item layout for the dynamodb-atomic backend: key encodings and attribute names (DynamoDbConstants) plus the entity, grant, and name-sentinel item mappers, with unit tests. Pure serialization layer; persistence logic and runtime wiring follow in later PRs.
---
persistence/dynamodb-atomic/build.gradle.kts | 7 +-
.../atomic/dynamodb/DynamoDbLayout.java | 97 ++++++++++++++
.../atomic/dynamodb/models/EntityItem.java | 146 +++++++++++++++++++++
.../atomic/dynamodb/models/NameSentinelItem.java | 60 +++++++++
.../atomic/dynamodb/DynamoDbLayoutTest.java | 51 +++++++
.../atomic/dynamodb/models/EntityItemTest.java | 109 +++++++++++++++
.../dynamodb/models/NameSentinelItemTest.java | 52 ++++++++
7 files changed, 521 insertions(+), 1 deletion(-)
diff --git a/persistence/dynamodb-atomic/build.gradle.kts b/persistence/dynamodb-atomic/build.gradle.kts
index fcd5e6c637..fb72a2b62d 100644
--- a/persistence/dynamodb-atomic/build.gradle.kts
+++ b/persistence/dynamodb-atomic/build.gradle.kts
@@ -22,4 +22,9 @@ plugins {
id("org.kordamp.gradle.jandex")
}
-dependencies { implementation(project(":polaris-core")) }
+dependencies {
+ implementation(project(":polaris-core"))
+
+ implementation(platform(libs.awssdk.bom))
+ implementation("software.amazon.awssdk:dynamodb")
+}
diff --git a/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayout.java b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayout.java
new file mode 100644
index 0000000000..e6cabc8276
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayout.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.persistence.atomic.dynamodb;
+
+/**
+ * Attribute names, item-type markers, and key encodings for the single-table DynamoDB layout.
+ *
+ * <p>One base table holds the Polaris item types, each partitioned by its own identity so load
+ * spreads across partitions; a sparse GSI serves the by-name lookup. The {@code realmId} is only a
+ * key prefix (tenant isolation), never a partition key on its own.
+ */
+public final class DynamoDbLayout {
+
+ private DynamoDbLayout() {}
+
+ // ---- Key attributes --------------------------------------------------------------------------
+ public static final String PK = "PK";
+ public static final String SK = "SK";
+
+ // by-name GSI (list children): gsi1pk = realmId#catalogId#parentId#typeCode, gsi1sk = name
+ public static final String GSI1 = "gsi1";
+ public static final String GSI1_PK = "gsi1pk";
+ public static final String GSI1_SK = "gsi1sk";
+
+ // ---- Sort-key / item-type markers ------------------------------------------------------------
+ public static final String SK_ENTITY = "ENTITY";
+ public static final String SK_NAME = "NAME";
+
+ // ---- Entity item attributes ------------------------------------------------------------------
+ public static final String A_CATALOG_ID = "catalogId";
+ public static final String A_PARENT_ID = "parentId";
+ public static final String A_TYPE_CODE = "typeCode";
+ public static final String A_SUB_TYPE_CODE = "subTypeCode";
+ public static final String A_NAME = "name";
+ public static final String A_ENTITY_VERSION = "entityVersion";
+ public static final String A_GRANT_RECORDS_VERSION = "grantRecordsVersion";
+ public static final String A_CREATE_TIMESTAMP = "createTimestamp";
+ public static final String A_DROP_TIMESTAMP = "dropTimestamp";
+ public static final String A_PURGE_TIMESTAMP = "purgeTimestamp";
+ public static final String A_TO_PURGE_TIMESTAMP = "toPurgeTimestamp";
+ public static final String A_LAST_UPDATE_TIMESTAMP = "lastUpdateTimestamp";
+ public static final String A_PROPERTIES = "properties";
+ public static final String A_INTERNAL_PROPERTIES = "internalProperties";
+
+ // ---- Name-sentinel attributes ----------------------------------------------------------------
+ public static final String A_ID = "id";
+
+ private static final String SEP = "#";
+
+ // ---- Key encodings ---------------------------------------------------------------------------
+
+ /** Entity partition key: {@code realmId#id} (one partition per entity). */
+ public static String entityPk(String realmId, long id) {
+ return realmId + SEP + id;
+ }
+
+ /**
+ * Name-sentinel partition key. {@code realmId} is length-prefixed so the key stays unambiguous
+ * when {@code realmId} or {@code name} contains the separator; both are free-form, whereas the
+ * numeric coordinates cannot contain it.
+ */
+ public static String sentinelPk(
+ String realmId, long catalogId, long parentId, int typeCode, String name) {
+ return realmId.length()
+ + SEP
+ + realmId
+ + SEP
+ + catalogId
+ + SEP
+ + parentId
+ + SEP
+ + typeCode
+ + SEP
+ + name;
+ }
+
+ /** by-name GSI partition key: {@code realmId#catalogId#parentId#typeCode}. */
+ public static String byNameGsiPk(String realmId, long catalogId, long parentId, int typeCode) {
+ return realmId + SEP + catalogId + SEP + parentId + SEP + typeCode;
+ }
+}
diff --git a/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItem.java b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItem.java
new file mode 100644
index 0000000000..2c8da2e048
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItem.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.persistence.atomic.dynamodb.models;
+
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_CATALOG_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_CREATE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_DROP_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_ENTITY_VERSION;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_GRANT_RECORDS_VERSION;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_INTERNAL_PROPERTIES;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_LAST_UPDATE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_NAME;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_PARENT_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_PROPERTIES;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_PURGE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_SUB_TYPE_CODE;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_TO_PURGE_TIMESTAMP;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_TYPE_CODE;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.GSI1_PK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.GSI1_SK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.PK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK_ENTITY;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.byNameGsiPk;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.entityPk;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.polaris.core.entity.PolarisBaseEntity;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+/**
+ * Maps a {@link PolarisBaseEntity} to and from its DynamoDB "entity item" representation.
+ *
+ * <p>The item is keyed by {@code PK = realmId#id}, {@code SK = "ENTITY"}, and carries the by-name
+ * GSI keys so a parent's children can be listed.
+ */
+public final class EntityItem {
+
+ private EntityItem() {}
+
+ /** Serialize an entity to a DynamoDB item map for the given realm. */
+ public static Map<String, AttributeValue> toItem(String realmId, PolarisBaseEntity e) {
+ Map<String, AttributeValue> item = new HashMap<>();
+ item.put(PK, stringAttr(entityPk(realmId, e.getId())));
+ item.put(SK, stringAttr(SK_ENTITY));
+
+ item.put(A_ID, numberAttr(e.getId()));
+ item.put(A_CATALOG_ID, numberAttr(e.getCatalogId()));
+ item.put(A_PARENT_ID, numberAttr(e.getParentId()));
+ item.put(A_TYPE_CODE, numberAttr(e.getTypeCode()));
+ item.put(A_SUB_TYPE_CODE, numberAttr(e.getSubTypeCode()));
+ item.put(A_NAME, stringAttr(e.getName()));
+ item.put(A_ENTITY_VERSION, numberAttr(e.getEntityVersion()));
+ item.put(A_GRANT_RECORDS_VERSION, numberAttr(e.getGrantRecordsVersion()));
+ item.put(A_CREATE_TIMESTAMP, numberAttr(e.getCreateTimestamp()));
+ item.put(A_DROP_TIMESTAMP, numberAttr(e.getDropTimestamp()));
+ item.put(A_PURGE_TIMESTAMP, numberAttr(e.getPurgeTimestamp()));
+ item.put(A_TO_PURGE_TIMESTAMP, numberAttr(e.getToPurgeTimestamp()));
+ item.put(A_LAST_UPDATE_TIMESTAMP, numberAttr(e.getLastUpdateTimestamp()));
+ item.put(A_PROPERTIES, mapAttr(e.getPropertiesAsMap()));
+ item.put(A_INTERNAL_PROPERTIES, mapAttr(e.getInternalPropertiesAsMap()));
+
+ // by-name GSI keys (sparse: only entity items carry them) -> serve "list children".
+ item.put(
+ GSI1_PK,
+ stringAttr(byNameGsiPk(realmId, e.getCatalogId(), e.getParentId(), e.getTypeCode())));
+ item.put(GSI1_SK, stringAttr(e.getName()));
+ return Map.copyOf(item);
+ }
+
+ /** Deserialize a DynamoDB entity item back into a {@link PolarisBaseEntity}. */
+ public static PolarisBaseEntity toEntity(Map<String, AttributeValue> item) {
+ return new PolarisBaseEntity.Builder()
+ .id(num(item, A_ID))
+ .catalogId(num(item, A_CATALOG_ID))
+ .parentId(num(item, A_PARENT_ID))
+ .typeCode((int) num(item, A_TYPE_CODE))
+ .subTypeCode((int) num(item, A_SUB_TYPE_CODE))
+ .name(str(item, A_NAME))
+ .entityVersion((int) num(item, A_ENTITY_VERSION))
+ .grantRecordsVersion((int) num(item, A_GRANT_RECORDS_VERSION))
+ .createTimestamp(num(item, A_CREATE_TIMESTAMP))
+ .dropTimestamp(num(item, A_DROP_TIMESTAMP))
+ .purgeTimestamp(num(item, A_PURGE_TIMESTAMP))
+ .toPurgeTimestamp(num(item, A_TO_PURGE_TIMESTAMP))
+ .lastUpdateTimestamp(num(item, A_LAST_UPDATE_TIMESTAMP))
+ .propertiesAsMap(strMap(item, A_PROPERTIES))
+ .internalPropertiesAsMap(strMap(item, A_INTERNAL_PROPERTIES))
+ .build();
+ }
+
+ // ---- small AttributeValue helpers (shared shape across item mappers) -------------------------
+
+ static AttributeValue stringAttr(String v) {
+ return AttributeValue.fromS(v);
+ }
+
+ static AttributeValue numberAttr(long v) {
+ return AttributeValue.fromN(Long.toString(v));
+ }
+
+ static AttributeValue mapAttr(Map<String, String> m) {
+ Map<String, AttributeValue> out =
+ m.entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> stringAttr(e.getValue())));
+ return AttributeValue.fromM(out);
+ }
+
+ static String str(Map<String, AttributeValue> item, String key) {
+ AttributeValue v = item.get(key);
+ return v == null ? null : v.s();
+ }
+
+ static long num(Map<String, AttributeValue> item, String key) {
+ AttributeValue v = item.get(key);
+ return v == null || v.n() == null ? 0L : Long.parseLong(v.n());
+ }
+
+ static Map<String, String> strMap(Map<String, AttributeValue> item, String key) {
+ AttributeValue v = item.get(key);
+ if (v == null || v.m() == null) {
+ return Map.of();
+ }
+ return v.m().entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().s()));
+ }
+}
diff --git a/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItem.java b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItem.java
new file mode 100644
index 0000000000..f745e0d168
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/main/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItem.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.persistence.atomic.dynamodb.models;
+
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.A_ID;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.PK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.SK_NAME;
+import static org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout.sentinelPk;
+import static org.apache.polaris.persistence.atomic.dynamodb.models.EntityItem.num;
+import static org.apache.polaris.persistence.atomic.dynamodb.models.EntityItem.numberAttr;
+import static org.apache.polaris.persistence.atomic.dynamodb.models.EntityItem.stringAttr;
+
+import java.util.HashMap;
+import java.util.Map;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+/**
+ * The name-sentinel item: a stand-in for a multi-column UNIQUE constraint. A small guard item keyed
+ * by the name's realm, catalog, parent, type, and the name itself, whose sole payload is the owning
+ * entity's {@code id}.
+ *
+ * <p>Its presence "claims" the name: a duplicate create fails its {@code attribute_not_exists}
+ * condition inside the same {@code TransactWriteItems} that writes the entity, so uniqueness holds
+ * atomically.
+ */
+public final class NameSentinelItem {
+
+ private NameSentinelItem() {}
+
+ public static Map<String, AttributeValue> toItem(
+ String realmId, long catalogId, long parentId, int typeCode, String name, long entityId) {
+ Map<String, AttributeValue> item = new HashMap<>();
+ item.put(PK, stringAttr(sentinelPk(realmId, catalogId, parentId, typeCode, name)));
+ item.put(SK, stringAttr(SK_NAME));
+ item.put(A_ID, numberAttr(entityId));
+ return Map.copyOf(item);
+ }
+
+ /** The entity id a sentinel points at, or 0 if absent. */
+ public static long entityId(Map<String, AttributeValue> item) {
+ return num(item, A_ID);
+ }
+}
diff --git a/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayoutTest.java b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayoutTest.java
new file mode 100644
index 0000000000..304bebe0d9
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/DynamoDbLayoutTest.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.persistence.atomic.dynamodb;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class DynamoDbLayoutTest {
+
+ @Test
+ void entityPkCombinesRealmAndId() {
+ assertThat(DynamoDbLayout.entityPk("realm1", 42L)).isEqualTo("realm1#42");
+ }
+
+ @Test
+ void sentinelPkCombinesParentCoordinatesAndName() {
+ assertThat(DynamoDbLayout.sentinelPk("realm1", 7L, 3L, 2, "my_table"))
+ .isEqualTo("6#realm1#7#3#2#my_table");
+ }
+
+ @Test
+ void sentinelPkStaysUniqueWhenRealmIdContainsSeparator() {
+ // Without the realmId length prefix these two distinct entities both encode to
+ // realm1#7#3#2#0#my_table and collide.
+ String separatorInRealmId = DynamoDbLayout.sentinelPk("realm1#7", 3L, 2L, 0, "my_table");
+ String separatorInName = DynamoDbLayout.sentinelPk("realm1", 7L, 3L, 2, "0#my_table");
+ assertThat(separatorInRealmId).isNotEqualTo(separatorInName);
+ }
+
+ @Test
+ void byNameGsiPkCombinesParentCoordinates() {
+ assertThat(DynamoDbLayout.byNameGsiPk("realm1", 7L, 3L, 2)).isEqualTo("realm1#7#3#2");
+ }
+}
diff --git a/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItemTest.java b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItemTest.java
new file mode 100644
index 0000000000..0f4bf3f4da
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/EntityItemTest.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.persistence.atomic.dynamodb.models;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Map;
+import org.apache.polaris.core.entity.PolarisBaseEntity;
+import org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+class EntityItemTest {
+
+ private static PolarisBaseEntity sampleEntity() {
+ return new PolarisBaseEntity.Builder()
+ .id(42L)
+ .catalogId(7L)
+ .parentId(3L)
+ .typeCode(2)
+ .subTypeCode(5)
+ .name("my_table")
+ .entityVersion(4)
+ .grantRecordsVersion(6)
+ .createTimestamp(1000L)
+ .dropTimestamp(0L)
+ .purgeTimestamp(0L)
+ .toPurgeTimestamp(0L)
+ .lastUpdateTimestamp(2000L)
+ .propertiesAsMap(Map.of("k1", "v1", "k2", "v2"))
+ .internalPropertiesAsMap(Map.of("ik", "iv"))
+ .build();
+ }
+
+ @Test
+ void toItemWritesKeysAndGsiAttributes() {
+ Map<String, AttributeValue> item = EntityItem.toItem("realm1", sampleEntity());
+
+ assertThat(item.get(DynamoDbLayout.PK).s()).isEqualTo("realm1#42");
+ assertThat(item.get(DynamoDbLayout.SK).s()).isEqualTo(DynamoDbLayout.SK_ENTITY);
+ assertThat(item.get(DynamoDbLayout.GSI1_PK).s()).isEqualTo("realm1#7#3#2");
+ assertThat(item.get(DynamoDbLayout.GSI1_SK).s()).isEqualTo("my_table");
+ }
+
+ @Test
+ void roundTripPreservesAllFields() {
+ PolarisBaseEntity original = sampleEntity();
+
+ PolarisBaseEntity back = EntityItem.toEntity(EntityItem.toItem("realm1", original));
+
+ assertThat(back.getId()).isEqualTo(42L);
+ assertThat(back.getCatalogId()).isEqualTo(7L);
+ assertThat(back.getParentId()).isEqualTo(3L);
+ assertThat(back.getTypeCode()).isEqualTo(2);
+ assertThat(back.getSubTypeCode()).isEqualTo(5);
+ assertThat(back.getName()).isEqualTo("my_table");
+ assertThat(back.getEntityVersion()).isEqualTo(4);
+ assertThat(back.getGrantRecordsVersion()).isEqualTo(6);
+ assertThat(back.getCreateTimestamp()).isEqualTo(1000L);
+ assertThat(back.getLastUpdateTimestamp()).isEqualTo(2000L);
+ assertThat(back.getPropertiesAsMap())
+ .containsExactlyInAnyOrderEntriesOf(Map.of("k1", "v1", "k2", "v2"));
+ assertThat(back.getInternalPropertiesAsMap())
+ .containsExactlyInAnyOrderEntriesOf(Map.of("ik", "iv"));
+ }
+
+ @Test
+ void emptyPropertyMapsRoundTripToEmpty() {
+ PolarisBaseEntity entity =
+ new PolarisBaseEntity.Builder()
+ .id(1L)
+ .catalogId(1L)
+ .parentId(0L)
+ .typeCode(1)
+ .subTypeCode(0)
+ .name("n")
+ .entityVersion(1)
+ .grantRecordsVersion(1)
+ .createTimestamp(1L)
+ .dropTimestamp(0L)
+ .purgeTimestamp(0L)
+ .toPurgeTimestamp(0L)
+ .lastUpdateTimestamp(1L)
+ .propertiesAsMap(Map.of())
+ .internalPropertiesAsMap(Map.of())
+ .build();
+
+ PolarisBaseEntity back = EntityItem.toEntity(EntityItem.toItem("realm1", entity));
+
+ assertThat(back.getPropertiesAsMap()).isEmpty();
+ assertThat(back.getInternalPropertiesAsMap()).isEmpty();
+ }
+}
diff --git a/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItemTest.java b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItemTest.java
new file mode 100644
index 0000000000..a8ef401ba1
--- /dev/null
+++ b/persistence/dynamodb-atomic/src/test/java/org/apache/polaris/persistence/atomic/dynamodb/models/NameSentinelItemTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.persistence.atomic.dynamodb.models;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Map;
+import org.apache.polaris.persistence.atomic.dynamodb.DynamoDbLayout;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+
+class NameSentinelItemTest {
+
+ @Test
+ void toItemKeysByNameCoordinatesAndCarriesEntityId() {
+ Map<String, AttributeValue> item =
+ NameSentinelItem.toItem("realm1", 7L, 3L, 2, "my_table", 42L);
+
+ assertThat(item.get(DynamoDbLayout.PK).s()).isEqualTo("6#realm1#7#3#2#my_table");
+ assertThat(item.get(DynamoDbLayout.SK).s()).isEqualTo(DynamoDbLayout.SK_NAME);
+ assertThat(item.get(DynamoDbLayout.A_ID).n()).isEqualTo("42");
+ }
+
+ @Test
+ void entityIdReadsBackTheOwningId() {
+ Map<String, AttributeValue> item =
+ NameSentinelItem.toItem("realm1", 7L, 3L, 2, "my_table", 42L);
+
+ assertThat(NameSentinelItem.entityId(item)).isEqualTo(42L);
+ }
+
+ @Test
+ void entityIdDefaultsToZeroWhenAbsent() {
+ assertThat(NameSentinelItem.entityId(Map.of())).isZero();
+ }
+}