Converting between Firestore FieldValue and Variant

Header image from Firebase blog titled Converting between Firestore FieldValue and Variant in C++
Header image from Firebase blog titled Converting between Firestore FieldValue and Variant in C++

What is a FieldValue?

The Cloud Firestore C++ SDK uses the firebase::firestore::FieldValue class to represent document fields. A FieldValue is a union type that may contain a primitive (like a boolean or a double), a container (e.g. an array), some simple structures (such as a Timestamp) or some Firestore-specific sentinel values (e.g. ServerTimestamp) and is used to write data to and read data from a Firestore database.

Other Firebase C++ SDKs use firebase::Variant for similar purposes. A Variant is also a union type that may contain primitives or containers of nested Variants; it is used, for example, to write data to and read data from the Realtime Database, to represent values read from Remote Config, and to represent the results of calling Cloud Functions using the Firebase SDK. If your application is migrating from Realtime Database to Firestore (or uses both side-by-side), or, for example, uses Firestore to store the results of Cloud Functions, you might need to convert between Variants and FieldValues.

In many ways, FieldValue and Variant are similar. However, it is important to understand that for all their similarities, neither is a subset of the other; rather, they can be seen as overlapping sets, each reflecting its domain. These differences make it impossible to write a general-purpose converter between them that would cover each and every case — instead, handling each instance where one type doesn’t readily map to the other would by necessity have to be application-specific.

With that in mind, let’s go through some sample code that provides one approach to conversion; if your application needs to convert between FieldValue and Variant, it should be possible to adapt this code to your needs. Full sample code is available here.

Converting primitives

The one area where FieldValues and Variants correspond to each other exactly are the primitive values. Both FieldValue and Variant support the exact same set of primitives, and conversion between them is straightforward:

FieldValue ConvertVariantToFieldValue(const Variant& from) {
  switch (from.type()) {
    case Variant::Type::kTypeNull:
      return FieldValue::Null();
    case Variant::Type::kTypeBool:
      return FieldValue::Boolean(from.bool_value());
    case Variant::Type::kTypeInt64:
      return FieldValue::Integer(from.int64_value());
    case Variant::Type::kTypeDouble:
      return FieldValue::Double(from.double_value());
  }
}

Variant Convert(const FieldValue& from) {
  switch (from.type()) {
    case FieldValue::Type::kNull:
      return Variant::Null();
    case FieldValue::Type::kBoolean:
      return Variant(from.boolean_value());
    case FieldValue::Type::kInteger:
      return Variant(from.integer_value());
    case FieldValue::Type::kDouble:
      return Variant(from.double_value());
  }
}

Strings and blobs

Variant distinguishes between mutable and static strings and blobs: a mutable string (or blob) is owned by the Variant and can be modified through its interface, whereas a static string (or blob) is not owned by the Variant (so the application needs to ensure it stays valid as long as the Variant’s lifetime hasn’t ended; typically, this is only used for static strings) and cannot be modified.

Firestore does not have this distinction — the strings and blobs held by FieldValue are always immutable (like static strings or blobs in Variant) but owned by the FieldValue (like mutable strings or blobs in Variant). Because ownership is the more important concern here, Firestore strings and blobs should be converted to mutable strings and blobs in Variant:

    // `FieldValue` -> `Variant`
    case FieldValue::Type::kString:
      return Variant(from.string_value());
    case FieldValue::Type::kBlob:
      return Variant::FromMutableBlob(from.blob_value(), from.blob_size());

    // `Variant` -> `FieldValue`
    case Variant::Type::kTypeStaticString:
    case Variant::Type::kTypeMutableString:
      return FieldValue::String(from.string_value());
    case Variant::Type::kTypeStaticBlob:
    case Variant::Type::kTypeMutableBlob:
      return FieldValue::Blob(from.blob_data(), from.blob_size());

Arrays and maps

Both FieldValues and Variants support arrays (called “vectors” by Variant) and maps, so for the most part, converting between them is straightforward:

    // `FieldValue` -> `Variant`
    case FieldValue::Type::kArray:
      return ConvertArray(from.array_value());
    case FieldValue::Type::kMap:
      return ConvertMap(from.map_value());
    }

    // `Variant` -> `FieldValue`
    case Variant::Type::kTypeVector:
      return ConvertArray(from.vector());
    case Variant::Type::kTypeMap:
      return ConvertMap(from.map());
    }
    // ...

FieldValue ConvertArray(const std::vector<Variant>& from) {
  std::vector<FieldValue> result;
  result.reserve(from.size());

  for (const auto& v : from) {
    result.push_back(Convert(v));
  }

  return FieldValue::Array(std::move(result));
}

FieldValue ConvertMap(const std::map<Variant, Variant>& from) {
  MapFieldValue result;

  for (const auto& kv : from) {
    // Note: Firestore only supports string keys. If it's possible
    // for the map to contain non-string keys, you would have to
    // convert them to a string representation or skip them.
    assert(kv.first.is_string());
    result[kv.first.string_value()] = Convert(kv.second);
  }

  return FieldValue::Map(std::move(result));
}

Variant ConvertArray(const std::vector<FieldValue>& from) {
  std::vector<Variant> result;
  result.reserve(from.size());

  for (const auto& v : from) {
    result.push_back(Convert(v));
  }

  return Variant(result);
}

Variant ConvertMap(const MapFieldValue& from) {
  std::map<Variant, Variant> result;

  for (const auto& kv : from) {
    result[Variant(kv.first)] = Convert(kv.second);
  }

  return Variant(result);
}

Nested arrays

Firestore does not support nested arrays (that is, one array being a direct member of another array). FieldValue itself would not reject a nested array, though — it will only be rejected by Firestore’s input validation when passed to a Firestore instance (like in a call to DocumentReference::Set).

The approach to handling this case would have to be application-specific. For example, you might simply omit nested arrays, perhaps logging a warning upon encountering them; on the other extreme, you may want to terminate the application:

FieldValue ConvertArray(const std::vector<Variant>& from) {
  std::vector<FieldValue> result;
  result.reserve(from.size());

  for (const auto& v : from) {
    if (v.type() == Variant::Type::kTypeVector) {
      // Potential approach 1: log and forget
      LogWarning("Skipping nested array");
      continue;
      // Potential approach 2: terminate
      assert(false && "Encountered a nested array");
      std::terminate();
    }
    result.push_back(Convert(v));
  }

  return FieldValue::Array(std::move(result));
}

Yet another approach might be to leave the nested arrays in place and rely on Firestore input validation to reject them (this approach is mostly applicable if you don’t expect your data to contain any nested arrays).

Translating nested arrays

One possible workaround if you need to pass a nested array to Firestore might be to represent arrays as maps:

  case Variant::Type::kTypeVector: {
    MapFieldValue result;
    const std::vector<Variant>& array = from.vector();
    for (int i = 0; i != array.size(); ++i) {
      result[std::to_string(i)] = Convert(array[i]);
    }
    return FieldValue::Map(std::move(result));
  }

Another approach, which has the nice property of being generalizable to other cases, is to automatically translate the structure of “array-array” into “array-map-array” when converting to FieldValue.

If you decide to use this approach, you will need to ensure that the translated structure roundtrips properly (assuming your application needs bidirectional conversion). That is, an “array-map-array” structure within a FieldValue converts back to an “array-array” structure in Variant. To achieve this, the artificial map would have to be somehow marked to indicate that it does not represent an actual value in the database.

Once again, the implementation for this would be application-specific. You could add a boolean field called “special” with its value set to true and establish a convention that a map that contains a “special” field never represents user input. If this is not true for your application, you might use a more distinct name than “special” or come up with a different convention altogether.

These next two examples use “special” as a marker, but please keep in mind that it’s just one possible approach:

// `Variant` -> `FieldValue`

FieldValue Convert(const Variant& from, bool within_array = false) {
  switch (from.type()) {
    // ...
    case Variant::Type::kTypeVector:
      if (!within_array) {
        return ConvertArray(from.vector());
      } else {
        // Firestore doesn't support nested arrays. As a workaround, create an
        // intermediate map to contain the nested array.
        return FieldValue::Map({
            {"special", FieldValue::Boolean(true)},
            {"type", FieldValue::String("nested_array")},
            {"value", ConvertArray(from.vector())},
        });
      }
    }
}

FieldValue ConvertArray(const std::vector<Variant>& from) {
  std::vector<FieldValue> result;
  result.reserve(from.size());

  for (const auto& v : from) {
    result.push_back(Convert(v, /*within_array=*/true));
  }

  return FieldValue::Array(std::move(result));
}

// `FieldValue` -> `Variant`

Variant Convert(const FieldValue& from) {
  switch (from.type()) {
    // ...
    case FieldValue::Type::kArray:
      return ConvertArray(from.array_value());
    case FieldValue::Type::kMap: {
      const auto& m = from.map_value();
      // Firestore doesn't support nested arrays, so nested arrays are instead
      // encoded as an "array-map-array" structure. Make sure nested arrays
      // round-trip.
      // Note: `TryGet*` functions are helpers to simplify getting values
      // out of maps. See their definitions in the full sample code.
      bool is_special = TryGetBoolean(m, "special");
      if (is_special) {
        return ConvertSpecialValue(m);
      } else {
        return ConvertMap(from.map_value());
      }
    }
}

Variant ConvertSpecialValue(const MapFieldValue& from) {
  // Note: in production code, you would have to handle
  // the case where the value is not in the map.
  // Note: `TryGet*` functions are helpers to simplify getting values
  // out of maps. See their definitions in the full sample code.
  std::string type = TryGetString(from, "type");

  if (type == "nested_array") {
    // Unnest the array.
    return ConvertArray(TryGetArray(from, "value"));
  }

  // ...
}

Firestore structs

Finally, there are several kinds of entities supported by FieldValue that have no direct equivalent in Variant:

  • Timestamp
  • GeoPoint
  • DocumentReference
  • Sentinel values (see below).

Similarly to nested arrays, your application could omit these values, issue errors upon encountering them, or else convert them into some representation supported by Variant. The exact representation would depend on the needs of your application and on whether the conversion is bidirectional or not (that is, whether it should be possible to convert the representation back into the original Firestore type).

An approach that is general (if somewhat heavyweight) and allows bidirectional conversion is to convert such structs into “special” maps. It could look like this:

// `FieldValue` -> `Variant`
  case FieldValue::Type::kTimestamp: {
      Timestamp ts = from.timestamp_value();
      MapFieldValue as_map = {
          {"special", FieldValue::Boolean(true)},
          {"type", FieldValue::String("timestamp")},
          {"seconds", FieldValue::Integer(ts.seconds())},
          {"nanoseconds", FieldValue::Integer(ts.nanoseconds())}};
      return ConvertMap(as_map);
    }

    case FieldValue::Type::kGeoPoint: {
      GeoPoint gp = from.geo_point_value();
      MapFieldValue as_map = {
          {"special", FieldValue::Boolean(true)},
          {"type", FieldValue::String("geo_point")},
          {"latitude", FieldValue::Double(gp.latitude())},
          {"longitude", FieldValue::Double(gp.longitude())}};
      return ConvertMap(as_map);
    }

    case FieldValue::Type::kReference: {
      DocumentReference ref = from.reference_value();
      std::string path = ref.path();
      MapFieldValue as_map = {
          {"special", FieldValue::Boolean(true)},
          {"type", FieldValue::String("document_reference")},
          {"document_path", FieldValue::String(path)}};
      return ConvertMap(as_map);
    }

FieldValue ConvertSpecialValue(const std::map<Variant, Variant>& from) {
  // Special values are Firestore entities encoded as maps because they are not
  // directly supported by `Variant`. The assumption is that the map contains
  // a boolean field "special" set to true and a string field "type" indicating
  // which kind of an entity it contains.

  std::string type = TryGetString(from, "type");

  if (type == "timestamp") {
    Timestamp result(TryGetInteger(from, "seconds"),
                     TryGetInteger(from, "nanoseconds"));
    return FieldValue::Timestamp(result);

  } else if (type == "geo_point") {
    GeoPoint result(TryGetDouble(from, "latitude"),
                    TryGetDouble(from, "longitude"));
    return FieldValue::GeoPoint(result);
}
// ...

The only complication here is that to convert a “special” map back to a DocumentReference, you would need a pointer to a Firestore instance so that you may call Firestore::Document. If your application always uses the default Firestore instance, you might simply call Firestore::GetInstance. Otherwise, you can pass Firestore* as an argument to Convert or make Convert a member function of a class, say, Converter, that acquires a pointer to a Firestore instance in its constructor.

} else if (type == "document_reference") {
    DocumentReference result =
        firestore->Document(TryGetString(from, "document_path"));
    return FieldValue::Reference(result);
  }

One more thing to note is that Realtime Database represents timestamps as the number of milliseconds since the epoch in UTC. If you intend to use the resulting Variant in the Realtime Database, a more natural representation for a Timestamp might thus be an integer field. However, you would have to provide some way to distinguish between numbers and timestamps in the Realtime Database — a possible solution is to simply add a _timestamp suffix to the field name, but of course other alternatives are possible. In that case, the conversion from FieldValue to Variant might look like:

case FieldValue::Type::kTimestamp: {
  Timestamp ts = from.timestamp_value();
  int64_t millis = ts.seconds() * 1000 + ts.nanoseconds() / (1000 * 1000);
  return Variant(millis);
}

If bidirectional conversion is required, you would also have to somehow distinguish between numbers and timestamps when converting back to a FieldValue. If you’re using the solution of adding _timestamp suffix to the field name, you would have to pass the field name to the converter. Another approach might be to use heuristics and presume that a very large number that readily converts to a reasonably recent date must be a timestamp.

Firestore sentinel values

Finally, there are some unique values in Firestore that represent a transformation to be applied to an existing value or a placeholder for a value to be supplied by the backend:

  • Delete
  • ServerTimestamp
  • ArrayUnion
  • ArrayRemove
  • IncrementInteger
  • IncrementDouble

Some of these values are only meaningful in Firestore, so most likely it wouldn’t make sense to try to convert them in your application. Otherwise, Delete and ServerTimestamp, being stateless, can be straightforwardly converted to maps using the approach outlined above. If you’re using Variant with the Realtime Database, you might want to represent a ServerTimestamp in the Realtime Database-specific format ( a map that contains a single element: {".sv" : "timestamp"}):

case FieldValue::Type::kServerTimestamp:
  return ConvertMap({{".sv", FieldValue::String("timestamp")}});

Similarly, you may represent Delete as a null in the ​​Realtime Database:

case FieldValue::Type::kDelete:
  return Variant::Null();

However, other than Delete and ServerTimestamp, the rest of the sentinel values are stateful and there is no way to get their underlying value from a FieldValue, so lossless conversion is not possible. Likely the best thing to do is just to ensure these values are never passed to the converter and assert if they do.