This is an automated email from the ASF dual-hosted git repository.

Abacn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new d79a2487a87 normalize firestoreIO (#39941)
d79a2487a87 is described below

commit d79a2487a8745b882560f029486d347c69a024fe
Author: Abdelrahman Ibrahim <[email protected]>
AuthorDate: Wed Sep 9 03:00:07 2026 +0300

    normalize firestoreIO (#39941)
    
    * normalize firestoreIO
    
    * move firestore to gcp_test extra
---
 .../beam_PostCommit_Yaml_Xlang_Direct.json         |   3 +-
 .../beam_PostCommit_Yaml_Xlang_Direct.yml          |   2 +-
 .../workflows/beam_PreCommit_Yaml_Xlang_Direct.yml |   2 +-
 .../FirestoreReadSchemaTransformConfiguration.java |  83 ++++++
 .../FirestoreReadSchemaTransformProvider.java      | 216 ++++++++++++++
 .../beam/sdk/io/gcp/firestore/FirestoreUtils.java  | 312 +++++++++++++++++++++
 .../beam/sdk/io/gcp/firestore/FirestoreV1.java     |   7 +-
 .../sdk/io/gcp/firestore/FirestoreV1ReadFn.java    |  38 ++-
 ...FirestoreWriteSchemaTransformConfiguration.java |  82 ++++++
 .../FirestoreWriteSchemaTransformProvider.java     | 201 +++++++++++++
 .../FirestoreSchemaTransformProviderTest.java      | 179 ++++++++++++
 .../sdk/io/gcp/firestore/FirestoreUtilsTest.java   |  65 +++++
 .../yaml/extended_tests/databases/firestore.yaml   |  84 ++++++
 sdks/python/apache_beam/yaml/integration_tests.py  |  54 ++++
 sdks/python/apache_beam/yaml/standard_io.yaml      |  27 ++
 sdks/python/build.gradle                           |   8 +-
 sdks/python/setup.py                               |   4 +
 17 files changed, 1337 insertions(+), 30 deletions(-)

diff --git a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json 
b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
index 0942404b0eb..86bf1193abd 100644
--- a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
@@ -1,5 +1,4 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run",
-  "revision": 3,
-  "https://github.com/apache/beam/pull/39990": "removing dead code from 
FnApiDoFnRunner"
+  "revision": 7
 }
diff --git a/.github/workflows/beam_PostCommit_Yaml_Xlang_Direct.yml 
b/.github/workflows/beam_PostCommit_Yaml_Xlang_Direct.yml
index a2de7f285ed..b390f8faf4e 100644
--- a/.github/workflows/beam_PostCommit_Yaml_Xlang_Direct.yml
+++ b/.github/workflows/beam_PostCommit_Yaml_Xlang_Direct.yml
@@ -82,7 +82,7 @@ jobs:
       - name: run PostCommit Yaml Xlang Direct script
         uses: ./.github/actions/gradle-command-self-hosted-action
         with:
-          gradle-command: :sdks:python:postCommitYamlIntegrationTests 
-PyamlTestSet=${{ matrix.test_set }} -PbeamPythonExtra=p310_ml_test,yaml
+          gradle-command: :sdks:python:postCommitYamlIntegrationTests 
-PyamlTestSet=${{ matrix.test_set }} 
-PbeamPythonExtra=p310_ml_test,yaml,gcp_test
       - name: Archive Python Test Results
         uses: actions/upload-artifact@v7
         if: failure()
diff --git a/.github/workflows/beam_PreCommit_Yaml_Xlang_Direct.yml 
b/.github/workflows/beam_PreCommit_Yaml_Xlang_Direct.yml
index cf36408537b..d92783c5318 100644
--- a/.github/workflows/beam_PreCommit_Yaml_Xlang_Direct.yml
+++ b/.github/workflows/beam_PreCommit_Yaml_Xlang_Direct.yml
@@ -95,7 +95,7 @@ jobs:
       - name: run PreCommit Yaml Xlang Direct script
         uses: ./.github/actions/gradle-command-self-hosted-action
         with:
-          gradle-command: :sdks:python:yamlIntegrationTests 
-PbeamPythonExtra=p310_ml_test,yaml
+          gradle-command: :sdks:python:yamlIntegrationTests 
-PbeamPythonExtra=p310_ml_test,yaml,gcp_test
       - name: Archive Python Test Results
         uses: actions/upload-artifact@v7
         if: failure()
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreReadSchemaTransformConfiguration.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreReadSchemaTransformConfiguration.java
new file mode 100644
index 00000000000..0a1e0e20e59
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreReadSchemaTransformConfiguration.java
@@ -0,0 +1,83 @@
+/*
+ * 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.beam.sdk.io.gcp.firestore;
+
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
+import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Configuration class for the Firestore Read transform. */
+@DefaultSchema(AutoValueSchema.class)
+@AutoValue
+public abstract class FirestoreReadSchemaTransformConfiguration implements 
Serializable {
+
+  @SchemaFieldDescription("GCP project id. Defaults to GcpOptions project when 
unset.")
+  @Nullable
+  public abstract String getProjectId();
+
+  @SchemaFieldDescription(
+      "Firestore database id. Defaults to FirestoreOptions firestoreDb when 
unset.")
+  @Nullable
+  public abstract String getDatabaseId();
+
+  @SchemaFieldDescription("Firestore collection id to read from.")
+  public abstract String getCollectionId();
+
+  @SchemaFieldDescription(
+      "The schema in which the data is encoded, defined with JSON-schema 
syntax "
+          + "(https://json-schema.org/).")
+  public abstract String getSchema();
+
+  @SchemaFieldDescription(
+      "This option specifies whether and where to output rows that failed to 
be read.")
+  @Nullable
+  public abstract ErrorHandling getErrorHandling();
+
+  public void validate() {
+    checkArgument(
+        getCollectionId() != null && !getCollectionId().isEmpty(),
+        "Firestore collection id must be specified.");
+    checkArgument(
+        getSchema() != null && !getSchema().isEmpty(), "Firestore schema must 
be specified.");
+  }
+
+  public static Builder builder() {
+    return new AutoValue_FirestoreReadSchemaTransformConfiguration.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setProjectId(String projectId);
+
+    public abstract Builder setDatabaseId(String databaseId);
+
+    public abstract Builder setCollectionId(String collectionId);
+
+    public abstract Builder setSchema(String schema);
+
+    public abstract Builder setErrorHandling(ErrorHandling errorHandling);
+
+    public abstract FirestoreReadSchemaTransformConfiguration build();
+  }
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreReadSchemaTransformProvider.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreReadSchemaTransformProvider.java
new file mode 100644
index 00000000000..c035a53acda
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreReadSchemaTransformProvider.java
@@ -0,0 +1,216 @@
+/*
+ * 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.beam.sdk.io.gcp.firestore;
+
+import com.google.auto.service.AutoService;
+import com.google.firestore.v1.Document;
+import com.google.firestore.v1.ListDocumentsRequest;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import org.apache.beam.sdk.extensions.gcp.options.GcpOptions;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.TypedSchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
+import org.apache.beam.sdk.schemas.utils.JsonUtils;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings;
+
+/** A {@link SchemaTransformProvider} for reading from Google Cloud Firestore. 
*/
+@AutoService(SchemaTransformProvider.class)
+@SuppressWarnings({
+  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
+})
+public class FirestoreReadSchemaTransformProvider
+    extends 
TypedSchemaTransformProvider<FirestoreReadSchemaTransformConfiguration> {
+
+  private static final String OUTPUT_TAG_NAME = "output";
+  public static final TupleTag<Row> OUTPUT_TAG = new TupleTag<Row>() {};
+  public static final TupleTag<Row> ERROR_TAG = new TupleTag<Row>() {};
+
+  private static final org.apache.beam.sdk.metrics.Counter errorCounter =
+      org.apache.beam.sdk.metrics.Metrics.counter(
+          FirestoreReadSchemaTransformProvider.class, 
"Firestore-read-error-counter");
+
+  @Override
+  protected SchemaTransform from(FirestoreReadSchemaTransformConfiguration 
configuration) {
+    return new FirestoreReadSchemaTransform(configuration);
+  }
+
+  @Override
+  public String identifier() {
+    return "beam:schematransform:org.apache.beam:firestore_read:v1";
+  }
+
+  @Override
+  public String description() {
+    return "Reads documents from a Google Cloud Firestore collection and 
outputs Beam Rows.";
+  }
+
+  @Override
+  public List<String> inputCollectionNames() {
+    return Collections.emptyList();
+  }
+
+  @Override
+  public List<String> outputCollectionNames() {
+    return Collections.singletonList(OUTPUT_TAG_NAME);
+  }
+
+  private static class FirestoreReadSchemaTransform extends SchemaTransform {
+    private final FirestoreReadSchemaTransformConfiguration configuration;
+
+    FirestoreReadSchemaTransform(FirestoreReadSchemaTransformConfiguration 
configuration) {
+      configuration.validate();
+      this.configuration = configuration;
+    }
+
+    @Override
+    public PCollectionRowTuple expand(PCollectionRowTuple input) {
+      if (!input.getAll().isEmpty()) {
+        throw new IllegalStateException(
+            "Firestore read transform does not expect input PCollections.");
+      }
+
+      Schema schema = 
JsonUtils.beamSchemaFromJsonSchema(configuration.getSchema());
+      String projectId = resolveProjectId(input.getPipeline());
+      String databaseId = resolveDatabaseId(input.getPipeline());
+      String parent = FirestoreUtils.documentsRoot(projectId, databaseId);
+
+      PCollection<ListDocumentsRequest> requests =
+          input
+              .getPipeline()
+              .apply("CreateCollectionId", 
Create.of(configuration.getCollectionId()))
+              .apply(
+                  "BuildListDocumentsRequest",
+                  ParDo.of(
+                      new DoFn<String, ListDocumentsRequest>() {
+                        @ProcessElement
+                        public void processElement(
+                            @Element String collectionId,
+                            OutputReceiver<ListDocumentsRequest> out) {
+                          out.output(
+                              ListDocumentsRequest.newBuilder()
+                                  .setParent(parent)
+                                  .setCollectionId(collectionId)
+                                  .build());
+                        }
+                      }));
+
+      FirestoreV1.ListDocuments.Builder readBuilder =
+          FirestoreIO.v1()
+              .read()
+              .listDocuments()
+              .withProjectId(projectId)
+              .withDatabaseId(databaseId);
+
+      PCollection<Document> documents = requests.apply("ReadFromFirestore", 
readBuilder.build());
+
+      boolean handleErrors = 
ErrorHandling.hasOutput(configuration.getErrorHandling());
+      Schema errorSchema = ErrorHandling.errorSchemaBytes();
+      String documentIdField = schema.hasField("document_id") ? "document_id" 
: null;
+
+      PCollectionTuple outputTuple =
+          documents.apply(
+              "ConvertToBeamRows",
+              ParDo.of(new DocumentToRowFn(schema, documentIdField, 
handleErrors, errorSchema))
+                  .withOutputTags(OUTPUT_TAG, TupleTagList.of(ERROR_TAG)));
+
+      PCollection<Row> rows = outputTuple.get(OUTPUT_TAG).setRowSchema(schema);
+      PCollectionRowTuple output = PCollectionRowTuple.of(OUTPUT_TAG_NAME, 
rows);
+      if (handleErrors && configuration.getErrorHandling() != null) {
+        output =
+            output.and(
+                configuration.getErrorHandling().getOutput(),
+                outputTuple.get(ERROR_TAG).setRowSchema(errorSchema));
+      }
+      return output;
+    }
+
+    private String resolveProjectId(org.apache.beam.sdk.Pipeline pipeline) {
+      if (!Strings.isNullOrEmpty(configuration.getProjectId())) {
+        return configuration.getProjectId();
+      }
+      FirestoreOptions firestoreOptions = 
pipeline.getOptions().as(FirestoreOptions.class);
+      if (!Strings.isNullOrEmpty(firestoreOptions.getFirestoreProject())) {
+        return firestoreOptions.getFirestoreProject();
+      }
+      String project = pipeline.getOptions().as(GcpOptions.class).getProject();
+      if (Strings.isNullOrEmpty(project)) {
+        throw new IllegalArgumentException(
+            "Firestore project id must be set on the transform or pipeline 
options.");
+      }
+      return project;
+    }
+
+    private String resolveDatabaseId(org.apache.beam.sdk.Pipeline pipeline) {
+      if (!Strings.isNullOrEmpty(configuration.getDatabaseId())) {
+        return configuration.getDatabaseId();
+      }
+      return pipeline.getOptions().as(FirestoreOptions.class).getFirestoreDb();
+    }
+  }
+
+  static class DocumentToRowFn extends DoFn<Document, Row> {
+    private final Schema schema;
+    private final @org.checkerframework.checker.nullness.qual.Nullable String 
documentIdField;
+    private final boolean handleErrors;
+    private final Schema errorSchema;
+
+    DocumentToRowFn(
+        Schema schema,
+        @org.checkerframework.checker.nullness.qual.Nullable String 
documentIdField,
+        boolean handleErrors,
+        Schema errorSchema) {
+      this.schema = schema;
+      this.documentIdField = documentIdField;
+      this.handleErrors = handleErrors;
+      this.errorSchema = errorSchema;
+    }
+
+    @ProcessElement
+    public void processElement(@Element Document document, MultiOutputReceiver 
receiver) {
+      try {
+        receiver
+            .get(OUTPUT_TAG)
+            .output(FirestoreUtils.documentToRow(document, schema, 
documentIdField));
+      } catch (Exception e) {
+        if (!handleErrors) {
+          throw new RuntimeException(
+              "Failed to convert Firestore document to Beam Row: " + 
document.getName(), e);
+        }
+        errorCounter.inc();
+        receiver
+            .get(ERROR_TAG)
+            .output(
+                ErrorHandling.errorRecord(
+                    errorSchema, 
document.getName().getBytes(StandardCharsets.UTF_8), e));
+      }
+    }
+  }
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreUtils.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreUtils.java
new file mode 100644
index 00000000000..0f754846013
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreUtils.java
@@ -0,0 +1,312 @@
+/*
+ * 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.beam.sdk.io.gcp.firestore;
+
+import com.google.firestore.v1.ArrayValue;
+import com.google.firestore.v1.Document;
+import com.google.firestore.v1.MapValue;
+import com.google.firestore.v1.Value;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.util.Timestamps;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.Schema.Field;
+import org.apache.beam.sdk.schemas.Schema.FieldType;
+import org.apache.beam.sdk.values.Row;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Instant;
+
+/** Utility methods for Firestore SchemaTransform providers. */
+@SuppressWarnings({
+  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
+})
+final class FirestoreUtils {
+
+  private FirestoreUtils() {}
+
+  static String documentsRoot(String projectId, String databaseId) {
+    return String.format("projects/%s/databases/%s/documents", projectId, 
databaseId);
+  }
+
+  static String documentPath(
+      String projectId, String databaseId, String collectionId, String 
documentId) {
+    return String.format(
+        "%s/%s/%s", documentsRoot(projectId, databaseId), collectionId, 
documentId);
+  }
+
+  static String documentIdFromName(String documentName) {
+    int lastSlash = documentName.lastIndexOf('/');
+    if (lastSlash < 0 || lastSlash == documentName.length() - 1) {
+      throw new IllegalArgumentException("Invalid Firestore document name: " + 
documentName);
+    }
+    return documentName.substring(lastSlash + 1);
+  }
+
+  static Row documentToRow(Document document, Schema schema, @Nullable String 
documentIdField) {
+    Map<String, Object> values = new HashMap<>();
+    for (Map.Entry<String, Value> entry : document.getFieldsMap().entrySet()) {
+      values.put(entry.getKey(), valueToJava(entry.getValue()));
+    }
+    if (documentIdField != null && schema.hasField(documentIdField)) {
+      values.put(documentIdField, documentIdFromName(document.getName()));
+    }
+    return toRow(values, schema);
+  }
+
+  static Document rowToDocument(
+      Row row,
+      Schema schema,
+      String projectId,
+      String databaseId,
+      String collectionId,
+      String documentIdField) {
+    String documentId = row.getString(documentIdField);
+    if (documentId == null || documentId.isEmpty()) {
+      throw new IllegalArgumentException(
+          "Document id field '" + documentIdField + "' must be set on input 
rows.");
+    }
+
+    Document.Builder builder =
+        Document.newBuilder()
+            .setName(documentPath(projectId, databaseId, collectionId, 
documentId));
+    for (Field field : schema.getFields()) {
+      String fieldName = field.getName();
+      if (fieldName.equals(documentIdField)) {
+        continue;
+      }
+      Object fieldValue = row.getValue(fieldName);
+      if (fieldValue != null) {
+        builder.putFields(fieldName, javaToValue(fieldValue, field.getType()));
+      }
+    }
+    return builder.build();
+  }
+
+  static Row toRow(Map<String, ?> values, Schema schema) {
+    Row.Builder rowBuilder = Row.withSchema(schema);
+    for (Field field : schema.getFields()) {
+      rowBuilder.addValue(convertFromJava(values.get(field.getName()), 
field.getType()));
+    }
+    return rowBuilder.build();
+  }
+
+  private static Map<String, ?> castToStringKeyMap(Map<?, ?> map) {
+    Map<String, Object> converted = new HashMap<>();
+    for (Map.Entry<?, ?> entry : map.entrySet()) {
+      converted.put(String.valueOf(entry.getKey()), entry.getValue());
+    }
+    return converted;
+  }
+
+  private static @Nullable Object valueToJava(Value value) {
+    switch (value.getValueTypeCase()) {
+      case STRING_VALUE:
+        return value.getStringValue();
+      case INTEGER_VALUE:
+        return value.getIntegerValue();
+      case DOUBLE_VALUE:
+        return value.getDoubleValue();
+      case BOOLEAN_VALUE:
+        return value.getBooleanValue();
+      case TIMESTAMP_VALUE:
+        return new Instant(Timestamps.toMillis(value.getTimestampValue()));
+      case BYTES_VALUE:
+        return value.getBytesValue().toByteArray();
+      case NULL_VALUE:
+        return null;
+      case ARRAY_VALUE:
+        List<@Nullable Object> values = new ArrayList<>();
+        for (Value element : value.getArrayValue().getValuesList()) {
+          values.add(valueToJava(element));
+        }
+        return values;
+      case MAP_VALUE:
+        Map<String, Object> map = new HashMap<>();
+        for (Map.Entry<String, Value> entry : 
value.getMapValue().getFieldsMap().entrySet()) {
+          map.put(entry.getKey(), valueToJava(entry.getValue()));
+        }
+        return map;
+      case VALUETYPE_NOT_SET:
+        return null;
+      default:
+        throw new IllegalArgumentException(
+            "Unsupported Firestore value type: " + value.getValueTypeCase());
+    }
+  }
+
+  private static Value javaToValue(Object value, FieldType fieldType) {
+    if (value == null) {
+      return 
Value.newBuilder().setNullValue(com.google.protobuf.NullValue.NULL_VALUE).build();
+    }
+    switch (fieldType.getTypeName()) {
+      case STRING:
+        return Value.newBuilder().setStringValue(value.toString()).build();
+      case INT64:
+        return Value.newBuilder().setIntegerValue(((Number) 
value).longValue()).build();
+      case DOUBLE:
+        return Value.newBuilder().setDoubleValue(((Number) 
value).doubleValue()).build();
+      case BOOLEAN:
+        return Value.newBuilder().setBooleanValue((Boolean) value).build();
+      case DATETIME:
+        Instant instant = (Instant) value;
+        return Value.newBuilder()
+            .setTimestampValue(Timestamps.fromMillis(instant.getMillis()))
+            .build();
+      case BYTES:
+        return Value.newBuilder().setBytesValue(ByteString.copyFrom((byte[]) 
value)).build();
+      case ARRAY:
+      case ITERABLE:
+        ArrayValue.Builder arrayBuilder = ArrayValue.newBuilder();
+        FieldType elementType = fieldType.getCollectionElementType();
+        if (elementType == null) {
+          throw new IllegalArgumentException("Collection element type cannot 
be null.");
+        }
+        for (Object item : (Iterable<?>) value) {
+          arrayBuilder.addValues(
+              item == null
+                  ? Value.newBuilder()
+                      .setNullValue(com.google.protobuf.NullValue.NULL_VALUE)
+                      .build()
+                  : javaToValue(item, elementType));
+        }
+        return Value.newBuilder().setArrayValue(arrayBuilder.build()).build();
+      case MAP:
+        MapValue.Builder mapBuilder = MapValue.newBuilder();
+        FieldType valueType = fieldType.getMapValueType();
+        if (valueType == null) {
+          throw new IllegalArgumentException("Map value type cannot be null.");
+        }
+        for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
+          Object mapValue = entry.getValue();
+          mapBuilder.putFields(
+              String.valueOf(entry.getKey()),
+              mapValue == null
+                  ? Value.newBuilder()
+                      .setNullValue(com.google.protobuf.NullValue.NULL_VALUE)
+                      .build()
+                  : javaToValue(mapValue, valueType));
+        }
+        return Value.newBuilder().setMapValue(mapBuilder.build()).build();
+      case ROW:
+        Schema rowSchema = fieldType.getRowSchema();
+        if (rowSchema == null) {
+          throw new IllegalArgumentException("Row schema cannot be null.");
+        }
+        if (!(value instanceof Row)) {
+          throw new IllegalArgumentException("Expected Row for nested field.");
+        }
+        MapValue.Builder nestedMapBuilder = MapValue.newBuilder();
+        Row nestedRow = (Row) value;
+        for (Field nestedField : rowSchema.getFields()) {
+          Object nestedValue = nestedRow.getValue(nestedField.getName());
+          if (nestedValue != null) {
+            nestedMapBuilder.putFields(
+                nestedField.getName(), javaToValue(nestedValue, 
nestedField.getType()));
+          }
+        }
+        return 
Value.newBuilder().setMapValue(nestedMapBuilder.build()).build();
+      default:
+        throw new IllegalArgumentException("Unsupported field type: " + 
fieldType);
+    }
+  }
+
+  private static @Nullable Object convertFromJava(@Nullable Object value, 
FieldType fieldType) {
+    if (value == null) {
+      return null;
+    }
+    switch (fieldType.getTypeName()) {
+      case BYTE:
+        return ((Number) value).byteValue();
+      case INT16:
+        return ((Number) value).shortValue();
+      case INT32:
+        return ((Number) value).intValue();
+      case INT64:
+        return ((Number) value).longValue();
+      case FLOAT:
+        return ((Number) value).floatValue();
+      case DOUBLE:
+        return ((Number) value).doubleValue();
+      case DECIMAL:
+        return value instanceof java.math.BigDecimal
+            ? value
+            : java.math.BigDecimal.valueOf(((Number) value).doubleValue());
+      case STRING:
+        return value.toString();
+      case BOOLEAN:
+        return value;
+      case DATETIME:
+        if (value instanceof Instant) {
+          return value;
+        }
+        if (value instanceof Number) {
+          return new Instant(((Number) value).longValue());
+        }
+        return Instant.parse(value.toString());
+      case BYTES:
+        if (value instanceof byte[]) {
+          return value;
+        }
+        return 
value.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
+      case ARRAY:
+      case ITERABLE:
+        if (!(value instanceof Iterable)) {
+          throw new IllegalArgumentException("Expected Iterable for array 
field.");
+        }
+        FieldType elementType = fieldType.getCollectionElementType();
+        if (elementType == null) {
+          throw new IllegalArgumentException("Collection element type cannot 
be null.");
+        }
+        List<@Nullable Object> rowList = new ArrayList<>();
+        for (Object item : (Iterable<?>) value) {
+          rowList.add(convertFromJava(item, elementType));
+        }
+        return rowList;
+      case MAP:
+        if (!(value instanceof Map)) {
+          throw new IllegalArgumentException("Expected Map for map field.");
+        }
+        FieldType valueType = fieldType.getMapValueType();
+        if (valueType == null) {
+          throw new IllegalArgumentException("Map value type cannot be null.");
+        }
+        Map<String, @Nullable Object> rowMap = new HashMap<>();
+        for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
+          rowMap.put(String.valueOf(entry.getKey()), 
convertFromJava(entry.getValue(), valueType));
+        }
+        return rowMap;
+      case ROW:
+        Schema rowSchema = fieldType.getRowSchema();
+        if (rowSchema == null) {
+          throw new IllegalArgumentException("Row schema cannot be null.");
+        }
+        if (value instanceof Map) {
+          return toRow(castToStringKeyMap((Map<?, ?>) value), rowSchema);
+        }
+        if (value instanceof Row) {
+          return value;
+        }
+        throw new IllegalArgumentException("Cannot convert value to Row.");
+      default:
+        throw new IllegalArgumentException("Unsupported field type: " + 
fieldType);
+    }
+  }
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1.java
index 66e4eca0d87..e8da6097788 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1.java
@@ -743,7 +743,12 @@ public final class FirestoreV1 {
               "listDocuments",
               ParDo.of(
                   new ListDocumentsFn(
-                      clock, firestoreStatefulComponentFactory, rpcQosOptions, 
readTime)))
+                      clock,
+                      firestoreStatefulComponentFactory,
+                      rpcQosOptions,
+                      readTime,
+                      projectId,
+                      databaseId)))
           .apply(ParDo.of(new ListDocumentsResponseToDocument()))
           .apply(Reshuffle.viaRandomKey());
     }
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1ReadFn.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1ReadFn.java
index 84e1cb1be0a..7c8f2bf345f 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1ReadFn.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1ReadFn.java
@@ -675,11 +675,12 @@ final class FirestoreV1ReadFn {
 
     protected final @Nullable Instant readTime;
 
+    private final @Nullable String configuredProjectId;
+    private final @Nullable String configuredDatabaseId;
+
     // transient running state information, not important to any possible 
checkpointing
     protected transient FirestoreStub firestoreStub;
     protected transient RpcQos rpcQos;
-    protected transient String projectId;
-    protected transient @Nullable String databaseId;
 
     @SuppressWarnings(
         "initialization.fields.uninitialized") // allow transient fields to be 
managed by component
@@ -696,10 +697,8 @@ final class FirestoreV1ReadFn {
           requireNonNull(firestoreStatefulComponentFactory, "firestoreFactory 
must be non null");
       this.rpcQosOptions = requireNonNull(rpcQosOptions, "rpcQosOptions must 
be non null");
       this.readTime = readTime;
-      if (projectId != null) {
-        this.projectId = projectId;
-      }
-      this.databaseId = databaseId;
+      this.configuredProjectId = projectId;
+      this.configuredDatabaseId = databaseId;
     }
 
     /** {@inheritDoc} */
@@ -712,33 +711,30 @@ final class FirestoreV1ReadFn {
     @Override
     public final void startBundle(StartBundleContext c) {
       String project =
-          this.projectId != null
-              ? this.projectId
+          configuredProjectId != null
+              ? configuredProjectId
               : 
c.getPipelineOptions().as(FirestoreOptions.class).getFirestoreProject();
       if (project == null) {
         project = c.getPipelineOptions().as(GcpOptions.class).getProject();
       }
-      projectId =
-          requireNonNull(
-              project,
-              "project must be defined on FirestoreOptions or GcpOptions of 
PipelineOptions");
-      databaseId =
-          this.databaseId != null
-              ? this.databaseId
+      String databaseId =
+          configuredDatabaseId != null
+              ? configuredDatabaseId
               : 
c.getPipelineOptions().as(FirestoreOptions.class).getFirestoreDb();
-      requireNonNull(
-          databaseId, "firestoreDb must be defined on FirestoreOptions of 
PipelineOptions");
       firestoreStub =
           firestoreStatefulComponentFactory.getFirestoreStub(
-              c.getPipelineOptions(), projectId, databaseId);
+              c.getPipelineOptions(),
+              requireNonNull(
+                  project,
+                  "project must be defined on FirestoreOptions or GcpOptions 
of PipelineOptions"),
+              requireNonNull(
+                  databaseId,
+                  "firestoreDb must be defined on FirestoreOptions of 
PipelineOptions"));
     }
 
     /** {@inheritDoc} */
-    @SuppressWarnings("nullness") // allow clearing transient fields
     @Override
     public void finishBundle() throws Exception {
-      projectId = null;
-      databaseId = null;
       firestoreStub.close();
     }
 
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreWriteSchemaTransformConfiguration.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreWriteSchemaTransformConfiguration.java
new file mode 100644
index 00000000000..1f56a7b7444
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreWriteSchemaTransformConfiguration.java
@@ -0,0 +1,82 @@
+/*
+ * 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.beam.sdk.io.gcp.firestore;
+
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
+import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Configuration class for the Firestore Write transform. */
+@DefaultSchema(AutoValueSchema.class)
+@AutoValue
+public abstract class FirestoreWriteSchemaTransformConfiguration implements 
Serializable {
+
+  @SchemaFieldDescription("GCP project id. Defaults to GcpOptions project when 
unset.")
+  @Nullable
+  public abstract String getProjectId();
+
+  @SchemaFieldDescription(
+      "Firestore database id. Defaults to FirestoreOptions firestoreDb when 
unset.")
+  @Nullable
+  public abstract String getDatabaseId();
+
+  @SchemaFieldDescription("Firestore collection id to write to.")
+  public abstract String getCollectionId();
+
+  @SchemaFieldDescription(
+      "Row field containing the document id. Defaults to document_id when 
unset.")
+  @Nullable
+  public abstract String getDocumentIdField();
+
+  @SchemaFieldDescription(
+      "This option specifies whether and where to output unwritable rows. 
Error handling is "
+          + "limited to data conversion failures before sending writes to 
Firestore.")
+  @Nullable
+  public abstract ErrorHandling getErrorHandling();
+
+  public void validate() {
+    checkArgument(
+        getCollectionId() != null && !getCollectionId().isEmpty(),
+        "Firestore collection id must be specified.");
+  }
+
+  public static Builder builder() {
+    return new AutoValue_FirestoreWriteSchemaTransformConfiguration.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setProjectId(String projectId);
+
+    public abstract Builder setDatabaseId(String databaseId);
+
+    public abstract Builder setCollectionId(String collectionId);
+
+    public abstract Builder setDocumentIdField(String documentIdField);
+
+    public abstract Builder setErrorHandling(ErrorHandling errorHandling);
+
+    public abstract FirestoreWriteSchemaTransformConfiguration build();
+  }
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreWriteSchemaTransformProvider.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreWriteSchemaTransformProvider.java
new file mode 100644
index 00000000000..4ff72477356
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreWriteSchemaTransformProvider.java
@@ -0,0 +1,201 @@
+/*
+ * 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.beam.sdk.io.gcp.firestore;
+
+import com.google.auto.service.AutoService;
+import com.google.firestore.v1.Document;
+import com.google.firestore.v1.Write;
+import java.util.Collections;
+import java.util.List;
+import org.apache.beam.sdk.extensions.gcp.options.GcpOptions;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.TypedSchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings;
+
+/** A {@link SchemaTransformProvider} for writing to Google Cloud Firestore. */
+@AutoService(SchemaTransformProvider.class)
+@SuppressWarnings({
+  "nullness" // TODO(https://github.com/apache/beam/issues/20497)
+})
+public class FirestoreWriteSchemaTransformProvider
+    extends 
TypedSchemaTransformProvider<FirestoreWriteSchemaTransformConfiguration> {
+
+  private static final String INPUT_TAG = "input";
+  private static final String DEFAULT_DOCUMENT_ID_FIELD = "document_id";
+  public static final TupleTag<Write> OUTPUT_TAG = new TupleTag<Write>() {};
+  public static final TupleTag<Row> ERROR_TAG = new TupleTag<Row>() {};
+
+  private static final org.apache.beam.sdk.metrics.Counter errorCounter =
+      org.apache.beam.sdk.metrics.Metrics.counter(
+          FirestoreWriteSchemaTransformProvider.class, 
"Firestore-write-error-counter");
+
+  @Override
+  protected SchemaTransform from(FirestoreWriteSchemaTransformConfiguration 
configuration) {
+    return new FirestoreWriteSchemaTransform(configuration);
+  }
+
+  @Override
+  public String identifier() {
+    return "beam:schematransform:org.apache.beam:firestore_write:v1";
+  }
+
+  @Override
+  public String description() {
+    return "Writes Beam Rows to a Google Cloud Firestore collection.";
+  }
+
+  @Override
+  public List<String> inputCollectionNames() {
+    return Collections.singletonList(INPUT_TAG);
+  }
+
+  @Override
+  public List<String> outputCollectionNames() {
+    return Collections.emptyList();
+  }
+
+  private static class FirestoreWriteSchemaTransform extends SchemaTransform {
+    private final FirestoreWriteSchemaTransformConfiguration configuration;
+
+    FirestoreWriteSchemaTransform(FirestoreWriteSchemaTransformConfiguration 
configuration) {
+      configuration.validate();
+      this.configuration = configuration;
+    }
+
+    @Override
+    public PCollectionRowTuple expand(PCollectionRowTuple input) {
+      PCollection<Row> rows = input.get(INPUT_TAG);
+      Schema inputSchema = rows.getSchema();
+      String projectId = resolveProjectId(input.getPipeline());
+      String databaseId = resolveDatabaseId(input.getPipeline());
+      String documentIdField =
+          Strings.isNullOrEmpty(configuration.getDocumentIdField())
+              ? DEFAULT_DOCUMENT_ID_FIELD
+              : configuration.getDocumentIdField();
+      if (!inputSchema.hasField(documentIdField)) {
+        throw new IllegalArgumentException(
+            "Input schema must contain document id field: " + documentIdField);
+      }
+
+      boolean handleErrors = 
ErrorHandling.hasOutput(configuration.getErrorHandling());
+      Schema errorSchema = ErrorHandling.errorSchema(inputSchema);
+
+      PCollectionTuple outputTuple =
+          rows.apply(
+              "ConvertToFirestoreWrite",
+              ParDo.of(
+                      new RowToWriteFn(
+                          inputSchema,
+                          projectId,
+                          databaseId,
+                          configuration.getCollectionId(),
+                          documentIdField,
+                          handleErrors,
+                          errorSchema))
+                  .withOutputTags(OUTPUT_TAG, TupleTagList.of(ERROR_TAG)));
+
+      FirestoreV1.Write write =
+          
FirestoreIO.v1().write().withProjectId(projectId).withDatabaseId(databaseId);
+
+      outputTuple.get(OUTPUT_TAG).apply("WriteToFirestore", 
write.batchWrite().build());
+
+      PCollection<Row> errorOutput = 
outputTuple.get(ERROR_TAG).setRowSchema(errorSchema);
+      ErrorHandling errorHandling = configuration.getErrorHandling();
+      return PCollectionRowTuple.of(
+          (handleErrors && errorHandling != null) ? errorHandling.getOutput() 
: "errors",
+          errorOutput);
+    }
+
+    private String resolveProjectId(org.apache.beam.sdk.Pipeline pipeline) {
+      if (!Strings.isNullOrEmpty(configuration.getProjectId())) {
+        return configuration.getProjectId();
+      }
+      FirestoreOptions firestoreOptions = 
pipeline.getOptions().as(FirestoreOptions.class);
+      if (!Strings.isNullOrEmpty(firestoreOptions.getFirestoreProject())) {
+        return firestoreOptions.getFirestoreProject();
+      }
+      String project = pipeline.getOptions().as(GcpOptions.class).getProject();
+      if (Strings.isNullOrEmpty(project)) {
+        throw new IllegalArgumentException(
+            "Firestore project id must be set on the transform or pipeline 
options.");
+      }
+      return project;
+    }
+
+    private String resolveDatabaseId(org.apache.beam.sdk.Pipeline pipeline) {
+      if (!Strings.isNullOrEmpty(configuration.getDatabaseId())) {
+        return configuration.getDatabaseId();
+      }
+      return pipeline.getOptions().as(FirestoreOptions.class).getFirestoreDb();
+    }
+  }
+
+  static class RowToWriteFn extends DoFn<Row, Write> {
+    private final Schema schema;
+    private final String projectId;
+    private final String databaseId;
+    private final String collectionId;
+    private final String documentIdField;
+    private final boolean handleErrors;
+    private final Schema errorSchema;
+
+    RowToWriteFn(
+        Schema schema,
+        String projectId,
+        String databaseId,
+        String collectionId,
+        String documentIdField,
+        boolean handleErrors,
+        Schema errorSchema) {
+      this.schema = schema;
+      this.projectId = projectId;
+      this.databaseId = databaseId;
+      this.collectionId = collectionId;
+      this.documentIdField = documentIdField;
+      this.handleErrors = handleErrors;
+      this.errorSchema = errorSchema;
+    }
+
+    @ProcessElement
+    public void processElement(@Element Row row, MultiOutputReceiver receiver) 
{
+      try {
+        Document document =
+            FirestoreUtils.rowToDocument(
+                row, schema, projectId, databaseId, collectionId, 
documentIdField);
+        
receiver.get(OUTPUT_TAG).output(Write.newBuilder().setUpdate(document).build());
+      } catch (Exception e) {
+        if (!handleErrors) {
+          throw new RuntimeException(e);
+        }
+        errorCounter.inc();
+        receiver.get(ERROR_TAG).output(ErrorHandling.errorRecord(errorSchema, 
row, e));
+      }
+    }
+  }
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreSchemaTransformProviderTest.java
 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreSchemaTransformProviderTest.java
new file mode 100644
index 00000000000..7d58ce8d551
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreSchemaTransformProviderTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.beam.sdk.io.gcp.firestore;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.Row;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for Firestore SchemaTransform providers. */
+@RunWith(JUnit4.class)
+public class FirestoreSchemaTransformProviderTest {
+
+  @Rule
+  public final transient TestPipeline pipeline =
+      TestPipeline.fromOptions(PipelineOptionsFactory.create())
+          .enableAbandonedNodeEnforcement(false);
+
+  @Test
+  public void testReadFindTransform() {
+    SchemaTransformProvider provider = loadReadProvider();
+
+    assertEquals(Lists.newArrayList("output"), 
provider.outputCollectionNames());
+    assertEquals(Lists.newArrayList(), provider.inputCollectionNames());
+    assertEquals("beam:schematransform:org.apache.beam:firestore_read:v1", 
provider.identifier());
+    assertNotNull(provider.description());
+
+    assertEquals(
+        Sets.newHashSet("project_id", "database_id", "collection_id", 
"schema", "error_handling"),
+        provider.configurationSchema().getFields().stream()
+            .map(Schema.Field::getName)
+            .collect(Collectors.toSet()));
+  }
+
+  @Test
+  public void testReadBuildTransform() {
+    FirestoreReadSchemaTransformConfiguration readConfig =
+        FirestoreReadSchemaTransformConfiguration.builder()
+            .setProjectId("test-project")
+            .setDatabaseId("(default)")
+            .setCollectionId("users")
+            .setSchema(
+                "{"
+                    + "\"type\":\"object\","
+                    + "\"properties\":{"
+                    + "\"document_id\":{\"type\":\"string\"},"
+                    + "\"name\":{\"type\":\"string\"}"
+                    + "},"
+                    + "\"required\":[\"document_id\",\"name\"]"
+                    + "}")
+            .build();
+
+    SchemaTransform transform = new 
FirestoreReadSchemaTransformProvider().from(readConfig);
+    PCollectionRowTuple output = 
transform.expand(PCollectionRowTuple.empty(pipeline));
+
+    assertEquals(1, output.getAll().size());
+    assertTrue(output.has("output"));
+    assertEquals(
+        
Schema.builder().addStringField("document_id").addStringField("name").build(),
+        output.get("output").getSchema());
+  }
+
+  @Test
+  public void testReadWithNonEmptyInputThrows() {
+    FirestoreReadSchemaTransformConfiguration readConfig =
+        FirestoreReadSchemaTransformConfiguration.builder()
+            .setProjectId("test-project")
+            .setCollectionId("users")
+            
.setSchema("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}")
+            .build();
+    SchemaTransform transform = new 
FirestoreReadSchemaTransformProvider().from(readConfig);
+
+    PCollection<Row> dummyInput =
+        pipeline.apply(
+            "CreateDummy", 
Create.empty(Schema.builder().addStringField("dummy").build()));
+    assertThrows(
+        IllegalStateException.class,
+        () -> transform.expand(PCollectionRowTuple.of("input", dummyInput)));
+  }
+
+  @Test
+  public void testWriteFindTransform() {
+    SchemaTransformProvider provider = loadWriteProvider();
+
+    assertEquals(Lists.newArrayList(), provider.outputCollectionNames());
+    assertEquals(Lists.newArrayList("input"), provider.inputCollectionNames());
+    assertEquals("beam:schematransform:org.apache.beam:firestore_write:v1", 
provider.identifier());
+    assertNotNull(provider.description());
+
+    assertEquals(
+        Sets.newHashSet(
+            "project_id", "database_id", "collection_id", "document_id_field", 
"error_handling"),
+        provider.configurationSchema().getFields().stream()
+            .map(Schema.Field::getName)
+            .collect(Collectors.toSet()));
+  }
+
+  @Test
+  public void testWriteBuildTransform() {
+    FirestoreWriteSchemaTransformConfiguration writeConfig =
+        FirestoreWriteSchemaTransformConfiguration.builder()
+            .setProjectId("test-project")
+            .setDatabaseId("(default)")
+            .setCollectionId("users")
+            .build();
+    SchemaTransform transform = new 
FirestoreWriteSchemaTransformProvider().from(writeConfig);
+    Schema schema = 
Schema.builder().addStringField("document_id").addStringField("name").build();
+    PCollection<Row> inputRows = pipeline.apply("CreateRows", 
Create.empty(schema));
+    PCollectionRowTuple output = 
transform.expand(PCollectionRowTuple.of("input", inputRows));
+    assertEquals(1, output.getAll().size());
+    assertTrue(output.has("errors"));
+  }
+
+  @Test
+  public void testWriteMissingDocumentIdFieldThrows() {
+    FirestoreWriteSchemaTransformConfiguration writeConfig =
+        FirestoreWriteSchemaTransformConfiguration.builder()
+            .setProjectId("test-project")
+            .setCollectionId("users")
+            .build();
+    SchemaTransform transform = new 
FirestoreWriteSchemaTransformProvider().from(writeConfig);
+    Schema schema = Schema.builder().addStringField("name").build();
+    PCollection<Row> inputRows = pipeline.apply("CreateInvalidRows", 
Create.empty(schema));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> transform.expand(PCollectionRowTuple.of("input", inputRows)));
+  }
+
+  private static SchemaTransformProvider loadReadProvider() {
+    List<SchemaTransformProvider> providers =
+        
StreamSupport.stream(ServiceLoader.load(SchemaTransformProvider.class).spliterator(),
 false)
+            .filter(provider -> provider.getClass() == 
FirestoreReadSchemaTransformProvider.class)
+            .collect(Collectors.toList());
+    return providers.get(0);
+  }
+
+  private static SchemaTransformProvider loadWriteProvider() {
+    List<SchemaTransformProvider> providers =
+        
StreamSupport.stream(ServiceLoader.load(SchemaTransformProvider.class).spliterator(),
 false)
+            .filter(provider -> provider.getClass() == 
FirestoreWriteSchemaTransformProvider.class)
+            .collect(Collectors.toList());
+    return providers.get(0);
+  }
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreUtilsTest.java
 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreUtilsTest.java
new file mode 100644
index 00000000000..b32075a00cc
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreUtilsTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.beam.sdk.io.gcp.firestore;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.firestore.v1.Document;
+import com.google.firestore.v1.Value;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.values.Row;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class FirestoreUtilsTest {
+
+  @Test
+  public void testDocumentRowRoundTrip() {
+    Schema schema = 
Schema.builder().addStringField("document_id").addStringField("name").build();
+    Row input = Row.withSchema(schema).addValues("doc-1", "Alice").build();
+    Document document =
+        FirestoreUtils.rowToDocument(
+            input, schema, "test-project", "(default)", "users", 
"document_id");
+    Row output = FirestoreUtils.documentToRow(document, schema, "document_id");
+
+    assertEquals("doc-1", output.getString("document_id"));
+    assertEquals("Alice", output.getString("name"));
+    assertEquals("Alice", 
document.getFieldsMap().get("name").getStringValue());
+  }
+
+  @Test
+  public void testDocumentIdFromName() {
+    assertEquals(
+        "doc-1",
+        
FirestoreUtils.documentIdFromName("projects/p/databases/(default)/documents/users/doc-1"));
+  }
+
+  @Test
+  public void testIntegerValueConversion() {
+    Schema schema = Schema.builder().addInt64Field("count").build();
+    Document document =
+        Document.newBuilder()
+            .setName("projects/p/databases/(default)/documents/users/doc-1")
+            .putFields("count", 
Value.newBuilder().setIntegerValue(42L).build())
+            .build();
+    Row row = FirestoreUtils.documentToRow(document, schema, null);
+    assertEquals(42L, row.getInt64("count").longValue());
+  }
+}
diff --git 
a/sdks/python/apache_beam/yaml/extended_tests/databases/firestore.yaml 
b/sdks/python/apache_beam/yaml/extended_tests/databases/firestore.yaml
new file mode 100644
index 00000000000..19f2574d057
--- /dev/null
+++ b/sdks/python/apache_beam/yaml/extended_tests/databases/firestore.yaml
@@ -0,0 +1,84 @@
+#
+# 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.
+#
+
+fixtures:
+  - name: firestore_vars
+    type: "apache_beam.yaml.integration_tests.temp_firestore_collection"
+    config:
+      project: "apache-beam-testing"
+      database: "firestoredb"
+
+pipelines:
+  - pipeline:
+      type: composite
+      transforms:
+        - type: Create
+          name: CreateData
+          config:
+            elements:
+              - {document_id: "a", name: "Alice"}
+              - {document_id: "b", name: "Bob"}
+        - type: WriteToFirestore
+          name: WriteData
+          input: CreateData
+          config:
+            project: "apache-beam-testing"
+            database: "firestoredb"
+            collection: '{firestore_vars[COLLECTION]}'
+            error_handling:
+              output: write_errors
+        - type: AssertEqual
+          input: WriteData.write_errors
+          config:
+            elements: []
+    options:
+      project: "apache-beam-testing"
+      firestore_db: "firestoredb"
+
+  - pipeline:
+      type: composite
+      transforms:
+        - type: ReadFromFirestore
+          name: ReadData
+          config:
+            project: "apache-beam-testing"
+            database: "firestoredb"
+            collection: '{firestore_vars[COLLECTION]}'
+            schema: |
+              {
+                "type": "object",
+                "properties": {
+                  "document_id": {"type": "string"},
+                  "name": {"type": "string"}
+                },
+                "required": ["document_id", "name"]
+              }
+            error_handling:
+              output: read_errors
+        - type: AssertEqual
+          input: ReadData
+          config:
+            elements:
+              - {document_id: "a", name: "Alice"}
+              - {document_id: "b", name: "Bob"}
+        - type: AssertEqual
+          input: ReadData.read_errors
+          config:
+            elements: []
+    options:
+      project: "apache-beam-testing"
+      firestore_db: "firestoredb"
diff --git a/sdks/python/apache_beam/yaml/integration_tests.py 
b/sdks/python/apache_beam/yaml/integration_tests.py
index 32794f4588d..71a2c3770c9 100644
--- a/sdks/python/apache_beam/yaml/integration_tests.py
+++ b/sdks/python/apache_beam/yaml/integration_tests.py
@@ -81,6 +81,10 @@ from google.cloud import pubsub_v1
 from google.cloud.bigtable import client
 from google.cloud.bigtable_admin_v2.types import instance
 
+try:
+  from google.cloud import firestore
+except ImportError:
+  firestore = None
 try:
   from google.cloud import secretmanager
 except ImportError:
@@ -165,6 +169,56 @@ def temp_spanner_table(project, prefix='temp_spanner_db_'):
     spanner_client._delete_database()
 
 
[email protected]
+def temp_firestore_collection(
+    project='apache-beam-testing',
+    database='firestoredb',
+    prefix='yaml_firestore_it_'):
+  """Context manager for an isolated Firestore collection used in YAML ITs.
+
+  Uses the shared Beam test project and the ``firestoredb`` database, matching
+  the Java Firestore integration tests.
+
+  Args:
+    project (str): GCP project id.
+    database (str): Firestore database id.
+    prefix (str): Prefix for the temporary collection name.
+
+  Yields:
+    dict: Keys ``PROJECT``, ``DATABASE``, and ``COLLECTION``.
+  """
+  if firestore is None:
+    raise RuntimeError("google-cloud-firestore is not installed.")
+
+  client = firestore.Client(project=project, database=database)
+  collection_id = f'{prefix}{uuid.uuid4().hex}'
+  logging.info(
+      'Using Firestore collection %s in project %s database %s',
+      collection_id,
+      project,
+      database)
+  try:
+    yield {
+        'PROJECT': project,
+        'DATABASE': database,
+        'COLLECTION': collection_id,
+    }
+  finally:
+    logging.info('Deleting documents in Firestore collection %s', 
collection_id)
+    collection_ref = client.collection(collection_id)
+    batch = client.batch()
+    pending = 0
+    for doc in collection_ref.stream():
+      batch.delete(doc.reference)
+      pending += 1
+      if pending >= 400:
+        batch.commit()
+        batch = client.batch()
+        pending = 0
+    if pending:
+      batch.commit()
+
+
 @contextlib.contextmanager
 def temp_bigquery_table(project, prefix='yaml_bq_it_'):
   """Context manager to create and clean up a temporary BigQuery dataset.
diff --git a/sdks/python/apache_beam/yaml/standard_io.yaml 
b/sdks/python/apache_beam/yaml/standard_io.yaml
index 253573c8069..173236dc5e9 100644
--- a/sdks/python/apache_beam/yaml/standard_io.yaml
+++ b/sdks/python/apache_beam/yaml/standard_io.yaml
@@ -514,6 +514,33 @@
       config:
         gradle_target: 
'sdks:java:io:google-cloud-platform:expansion-service:shadowJar'
 
+# Firestore
+- type: renaming
+  transforms:
+    'ReadFromFirestore': 'ReadFromFirestore'
+    'WriteToFirestore': 'WriteToFirestore'
+  config:
+    mappings:
+      'ReadFromFirestore':
+        project: 'project_id'
+        database: 'database_id'
+        collection: 'collection_id'
+        schema: 'schema'
+        error_handling: 'error_handling'
+      'WriteToFirestore':
+        project: 'project_id'
+        database: 'database_id'
+        collection: 'collection_id'
+        document_id_field: 'document_id_field'
+        error_handling: 'error_handling'
+    underlying_provider:
+      type: beamJar
+      transforms:
+        'ReadFromFirestore': 
'beam:schematransform:org.apache.beam:firestore_read:v1'
+        'WriteToFirestore': 
'beam:schematransform:org.apache.beam:firestore_write:v1'
+      config:
+        gradle_target: 
'sdks:java:io:google-cloud-platform:expansion-service:shadowJar'
+
 # TFRecord
 - type: renaming
   transforms:
diff --git a/sdks/python/build.gradle b/sdks/python/build.gradle
index 339d87c76cb..b445d042bbc 100644
--- a/sdks/python/build.gradle
+++ b/sdks/python/build.gradle
@@ -157,8 +157,8 @@ tasks.register("yamlIntegrationTests") {
   // grep -oh 'sdk.*Jar' sdks/python/apache_beam/yaml/*.yaml | sort | uniq
   dependsOn ":sdks:java:extensions:schemaio-expansion-service:shadowJar"
   dependsOn ":sdks:java:extensions:sql:expansion-service:shadowJar"
-  dependsOn ":sdks:java:io:expansion-service:build"
-  dependsOn ":sdks:java:io:google-cloud-platform:expansion-service:build"
+  dependsOn ":sdks:java:io:expansion-service:shadowJar"
+  dependsOn ":sdks:java:io:google-cloud-platform:expansion-service:shadowJar"
   dependsOn ":sdks:java:io:messaging-expansion-service:shadowJar"
 
   doLast {
@@ -177,8 +177,8 @@ tasks.register("postCommitYamlIntegrationTests") {
   // grep -oh 'sdk.*Jar' sdks/python/apache_beam/yaml/*.yaml | sort | uniq
   dependsOn ":sdks:java:extensions:schemaio-expansion-service:shadowJar"
   dependsOn ":sdks:java:extensions:sql:expansion-service:shadowJar"
-  dependsOn ":sdks:java:io:expansion-service:build"
-  dependsOn ":sdks:java:io:google-cloud-platform:expansion-service:build"
+  dependsOn ":sdks:java:io:expansion-service:shadowJar"
+  dependsOn ":sdks:java:io:google-cloud-platform:expansion-service:shadowJar"
   dependsOn ":sdks:java:io:debezium:expansion-service:shadowJar"
   dependsOn ":sdks:java:io:snowflake:expansion-service:shadowJar"
   dependsOn ":sdks:java:io:amazon-web-services2:expansion-service:shadowJar"
diff --git a/sdks/python/setup.py b/sdks/python/setup.py
index 5b02dc9ab0e..601b3fcffee 100644
--- a/sdks/python/setup.py
+++ b/sdks/python/setup.py
@@ -557,6 +557,10 @@ if __name__ == '__main__':
               'orjson>=3.9.7,<4',
               'regex>=2020.6.8',
           ],
+          # GCP packages used only by tests/ITs (not native Python IO).
+          'gcp_test': [
+              'google-cloud-firestore>=2.0.0,<3',
+          ],
           'interactive': [
               'facets-overview>=1.1.0,<2',
               'google-cloud-dataproc>=5.0.0,<6',

Reply via email to