Skip to content

FB4D Reference IFirestoreDocument

Christoph Schneider edited this page Jun 11, 2024 · 26 revisions

Interface IFirestoreDocument

The Firestore database stores and retrieves documents with structured data. Documents can contain either a simple list of fields as well as complex nested objects organized in arrays and records (maps). Documents are organized in collections. In most cases, a collection contains multiple identical structured documents comparable to records in a table of a relational database. However, a collection is more flexible and may contain differently structured documents.

In FB4D, the interface IFirestoreDocument enables read access to documents retrieved from the Firestore and write access to documents that will be written to the Firestore database.

Write Access

The constructor TFirestoreDocument.Create in the unit FB4D.Document allows to create an empty document that can be written later into the database by using the method IFirestoreDatabase.InsertOrUpdateDocument or IFirestoreDatabase.PatchDocument.

constructor TFirestoreDocument.Create(DocumentPath: TRequestResourceParam;
  const ProjectID: string; const Database: string = cDefaultDatabaseID);

Note: Since version 1.5 the full path of the document is required as well as the ProjectID and optionally the database ID. In the older versions only the document ID was required.

There is a new object to document wrapper that simplifies read and write access. It is recommended to use this approach for new projects: More information can be found in the chapter "Write Pascal Objects as Document by using the Object to Document Mapper". The classic approach for writing fields manually into the document is described below.

To add content to the document, the AddOrUpdateField method allows you to insert a new field or, if the field already exists, to overwrite it with new content. As field value a JSON value is expected. Because the Firestore expects field data with the field name, the declared data type and the value in a Firestore specific JSON format. For this purpose the unit FB4D.Helpers contains a class helper for TJSONObject that offers several class functions to build all types of Firestore fields. The following example shows how easy it is to generate a string value that is added to the field named strField:

Doc.AddOrUpdateField(TJSONObject.SetString('strField', 'This is the string value'));

The following field types are supported in Firestore and FB4D:

TFirestoreFieldType = (fftNull, fftBoolean, fftInteger, fftDouble,
 fftTimeStamp, fftString, fftBytes, fftReference, fftGeoPoint, fftArray,
 fftMap);

For this purpose the TJSONHelper class helper offers these functions that returns a TJSONPair for the field name and the field body.

class function SetString(const VarName, Val: string): TJSONPair;
class function SetInteger(const VarName: string; Val: integer): TJSONPair;
class function SetInt64Value(Val: Int64): TJSONObject;
class function SetBoolean(const VarName: string; Val: boolean): TJSONPair;
class function SetDouble(const VarName: string; Val: double): TJSONPair;
class function SetTimeStamp(const VarName: string; Val: TDateTime): TJSONPair;
class function SetNull(const VarName: string): TJSONPair;
class function SetReference(const Name, ProjectID, Ref: string): TJSONPair;
class function SetGeoPoint(const VarName: string; Val: TLocationCoord2D): TJSONPair;
class function SetBytes(const VarName: string; Val: TBytes): TJSONPair;
class function SetMap(const VarName: string; MapVars: array of TJSONPair): TJSONPair;
class function SetMapValue(MapVars: array of TJSONPair): TJSONObject;
class function SetArray(const VarName: string; ArrayVars: array of TJSONValue): TJSONPair;
class function SetStringArray(const VarName: string; Strings: TStringDynArray|TStringList|TList<string>): TJSONPair; overload;

The type array and map are nested data types that are built by combinations of other simple data types.

The map type is comparable to a record in Pascal. The following example creates a map that contains two subfields: The string field MapStr and the integer field MapInt:

IFirestoreDocument.AddOrUpdateField(TJSONObject.SetMap('MyMap', [
   TJSONObject.SetString('MapStr', 'Map would be called in Delphi "record"'),
   TJSONObject.SetInteger('MapInt', 324)]);

The array type is more flexible than its pendant in Pascal. The array type is more flexible than an array in Pascal because each element can store a different type. Unlike the type map, the array does not have a field name per element.

In order to build nested data structures with arrays, the TJSONHelpers class helper offers for each field type a second class functions to build just the field body without a field name. This second function is named Set<Type>Value and requires only the field value as a parameter:

class function SetString(const VarName, Val: string): TJSONPair;
class function SetStringValue(const Val: string): TJSONObject;

The following example creates an array that contains three elements: Firstly a string, secondly an integer and thirdly a boolean field:

TJSONObject.SetArray('MyArr', 
  [TJSONObject.SetStringValue('Element0'),
   TJSONObject.SetIntegerValue(1),
   TJSONObject.SetBooleanValue(true)]);

In arrays, each element often contains an identical map as the following example shows:

TJSONObject.SetArray('MyCompoundArr', 
 [TJSONObject.SetMapValue(
   [TJSONObject.SetString('FieldA', 'Delphi rocks with Firestore 👨'),
    TJSONObject.SetInteger('FieldB', 4711)],
 [TJSONObject.SetMapValue(
   [TJSONObject.SetString('FieldA', 'A second map element'),
    TJSONObject.SetInteger('FieldB', 4712)]]);

For the sake of completeness, it should also be mentioned that the function AddOrUpdateField offers an overloaded variant with field name and field-body as a parameter:

IFirestoreDocument = interface(IInterface)
  function AddOrUpdateField(Field: TJSONPair): IFirestoreDocument; overload;
  function AddOrUpdateField(const FieldName: string; Val: TJSONValue): IFirestoreDocument; overload;

Hint: The AddOrUpdateField method supports optionally the use of fluent interface design os you can easily add multiple fields in one step without the need of store the IFirestoreDocument in a local variable because you can pass it directly to the consuming method e.g. CreateDocument.

Read Access

When you read a document from the database, the document object is usually created by IFirestoreDatabase. Therefore, the document does not have to be created in the application code.

Following read functions offers to read the header information and the structure of a document:

FirestoreDocument = interface(IInterface)
  function DocumentName(FullPath: boolean): string;
  function DocumentFullPath: TRequestResourceParam;
  function DocumentPathWithinDatabase: TRequestResourceParam;
  function CreateTime(TimeZone: TTimeZone = tzUTC): TDateTime;
  function UpdateTime(TimeZone: TTimeZone = tzUTC): TDatetime;
  function CountFields: integer;
  function Fields(Ind: integer): TJSONObject;
  function FieldName(Ind: integer): string;
  function FieldByName(const FieldName: string): TJSONObject;
  function FieldValue(Ind: integer): TJSONObject;
  function FieldType(Ind: integer): TFirestoreFieldType;
  function FieldTypeByName(const FieldName: string): TFirestoreFieldType;
  function AllFields: TStringDynArray;

The function DocumentName returns the entire path of the document in the Firestore if the boolean parameter is true. If false, the function returns only the document ID in the surrounding collection.

The function DocumentFullPath returns the entire path of the document including the project and database identifier split into an array of strings.

The function DocumentPathWithinDatabase returns do the same but without the project and database identifier.

The function CreateTime returns the time when a document was first written into the database. As long the document is still under construction in FB4D there is no creation time available. With the optional parameter TimeZone you can convert the time as local time when using tzLocalTime.

The function UpdateTime returns the timestamp of the last write access for this document. With the optional parameter TimeZone you can convert the time as local time when using tzLocalTime.

With the function CountFields and the function Fields(Ind) you can read out the root fields of the document. The function FieldType(Ind) informs about the field type (fftNull..fftMap) of the field.

The function AllFields returns the list of fields in the document as array of string.

There is a new object to document wrapper that simplifies read and write access. It is recommended to use this approach for new projects: More information can be found in the chapter "Read Pascal Objects from Document by using the Object to Document Mapper". The classic approach for reading fields manually from the document is described in the next chapter.

Read Data of Simple Field Types from Documents

Most of the time you have an expectation regarding the field structure of the read document, so you can use the function FieldByName which returns a TJSONObject. In this way, you can quickly access their field contents via the name and the known field type. With the help of the class helper for TJSONObject in the unit FB4D.Helpers you can easily read a value of the known type.

For root fields, the interface IFirestoreDocument provides an even faster way:

function IFirestoreDocument.GetStringValue(StringFieldName: string): string;
function IFirestoreDocument.GetIntegerValue(IntegerFieldName: string): integer;
function IFirestoreDocument.GetDoubleValue(DoubleFieldName: string): double;
function IFirestoreDocument.GetTimeStampValue(TimeStampFieldName: string): TDateTime;
function IFirestoreDocument.GetBoolValue(BooleanFieldName: string): boolean;
function IFirestoreDocument.GetGeoPoint(GeoFieldName: string): TLocationCoord2DValue;
function IFirestoreDocument.GetReference(ReferenceFieldName: string): string;
function IFirestoreDocument.GetBytes(ByteFieldName: string): TBytes;

If the field in the document is optional, there are additional methods for each field type that require a default value. While GetStringValue throws an exception if the field is not present in the document, the function GetStringValueDef(const FieldName, Default: string): string returns the default value in the same situation.

Read Nested Data Structures from Documents

Read Arrays

For read arrays, the IFirestoreDocument interface offers the following functions:

function GetArraySize(const FieldName: string): integer;
function GetArrayType(const FieldName: string; Index: integer): TFirestoreFieldType;
function GetArrayItem(const FieldName: string; Index: integer): TJSONPair;
function GetArrayValue(const FieldName: string; Index: integer): TJSONValue;
function GetArrayValues(const FieldName: string): TJSONObjects;
function GetArrayStringValues(const FieldName: string): TStringDynArray;

While GetArraySize returns the number of element, the function GetArrayType(Index) informs about the element type which can be different for each element! Note that in this point an array of Firebase differs significantly from an array in Delphi.

In order to get the element of an array, you can use the method GetArrayValue(Index) that returns a TJSONValue. To access the value of such array element, use the same class helper functions as GetStringValue and GetIntegerValue without passing in a field name because all the elements in an array are nameless.

For sub-arrays, the TJSONObject class helper function GetArraySize returns the number of elements in an array. To access an element of the array, you can use the following class helper function:

function GetArrayItem(Ind: integer): TJSONObject;

Often in Firebase documents, data are structured within an array of maps. To access directly the elements of the maps the following method can be used:

function GetArrayMapValues(const FieldName: string): TJSONObjects;

Read Maps

For read maps (similar to records in Delphi), the IFirestoreDocument interface offers the following functions:

function GetMapSize(const FieldName: string): integer;
function GetMapSubFieldName(const FieldName: string; Index: integer): string;
function GetMapType(const FieldName: string; Index: integer): TFirestoreFieldType;
function GetMapValue(const FieldName: string; Index: integer): TJSONObject; overload;
function GetMapValue(const FieldName, SubFieldName: string): TJSONObject; overload;
function GetMapValues(const FieldName: string): TJSONObjects;

For fast access to the map subfield name in a map use the function GetMapSubFieldName for an index between 0 and GetMapSize - 1:

Doc.GetMapSubFieldName('MyMap', Index)

For fast access to a variable in a map use the function GetMapValue(const FieldName, SubFieldName: string).

E.g. after creating a complex document in the DemoFB4D application, you can address the MapStr in MyMap with this new function as follows:

Doc.GetMapValue('MyMap', 'MapStr').GetStringValue returns 'Map would be called in Delphi "record"'.

Doc.GetMapValue(MyMap', 2).GetStringValue returns the same string in the DemoFB4D application.

Alternatively, for sub-maps, you get the number of elements by the TJSONObject class helper method GetMapSize. To access an element of the map, you can use the following class helper function:

function GetMapItem(Ind: integer): TJSONPair;

Here you get a TJSONPair that contains the name of the element and the value as TJSONObject. Note that elements in maps are not nameless like elements of arrays.

Write Pascal Objects as Document by using the Object to Document Mapper

Since version 1.6 FB4D offers a much easier solution for writing and reading documents in the Firestore instead of adressing and filling all fields manually.

To do this, you only need to derive your own class from TFirestoreDocument, in which your own fields are declared. The method TFirestoreDocument.SaveObjectToDocument then transfers all fields of the class to a new document using Delphi RTTI technology. The following example shows a simple application for writing a document with an string and integer field.

type
  TMyFirestoreDocument = class(TFirestoreDocument)
  public
    MyStringField: string;
    MyIntField: integer;
  end;

var 
  Doc: TMyFirestoreDocument; 
begin
  Doc := TMyFirestoreDocument.Create(['MyCol','DocId'], Config.Database);
  try
    Doc.MyStringField := 'MyTest’; 
    Doc.MyIntField := 4711; 
    Database.InsertOrUpdateDocumentSynchronous(Doc.SaveObjectToDocument);
  finally
    Doc.Free;
  end;
end;

This code originates from the FB4D_Samples Project FSObj2Doc.

Instead of introducing class attributes as other object mappers do, the ObjToDoc mapper uses an optional set of options: TOTDMapperOptions.

See the following defintion from FB4D.Interfaces:

type
  TOTDMapperOption = (
    omSupressSaveDefVal, // Don't save empty string, integer with value 0, etc..
    omSupressSavePrivateFields,   // Don't save private fields
    omSupressSaveProtectedFields, // Don't save protected fields
    omSupressSavePublicFields,    // Don't save public fields
    omSupressSavePublishedFields, // Don't save published fields
    omEliminateFieldPrefixF,      // Eliminate F and f as field prefix
    omSaveEnumAsString);          // Convert Enum to string instead of ord number
  TOTDMapperOptions = set of TOTDMapperOption;

  IFirestoreDocument = interface(IInterface)
    function SaveObjectToDocument(Options: TOTDMapperOptions = []): IFirestoreDocument;
  end;

In a Firestore document, it is recommended to write only those fields that are actually used in the specific document. The omSupressSaveDefVal option is used for this purpose and suppresses the writing of empty fields that contain a default value.

In order to create fields for internal management, e.g. of business logic, in addition to the fields that are to be written to the document, there are 4 types for suppressing the object to document wrapper for fields due to their visibility:

  • omSupressSavePrivateFields, // Don't save private fields
  • omSupressSaveProtectedFields, // Don't save protected fields
  • omSupressSavePublicFields, // Don't save public fields
  • omSupressSavePublishedFields, // Don't save published fields

Note: It makes no sense to set all these four options, because then no fields will be transferred to the document.

In Delphi, it is common for fields to have a prefix f (lower or upper case). The omEliminateFieldPrefixF option is used to remove this prefix.

The omSaveEnumAsString option can be used to control that enumerators are written to the Firestore document as a string and not as an ordinal number.

Notes on data types supported by the mapper

There are restrictions on the declaration of fields that can be written to documents by the mapper. This means that references to other classes cannot be processed by the mapper. Records, arrays and dynamic arrays as well as enumerators and sets are supported and can be used nested. Note the maximum nesting depth of 20, which is predetermined by the Firestore.

The following class shows a comprehensive application from the demo project FSObj2Doc_VCL.

type
  TMyEnum = (_Alpha, _Beta, _Gamma);
  TMySet = set of TMyEnum;
  TMyFSDoc = class(TFirestoreDocument)
  private
    fMyPrivate: string;
  protected
    fMyProtected: string;
  public
    // The class TMyFSDoc contains member variables for
    // synchronization from and to the Firestore.
    // Replace these members with specific fields required in your project
    DocTitle: string;
    Msg: AnsiString;
    Ch: Char;
    CreationDateTime: TDateTime;
    TestInt: integer;
    LargeNumber: Int64;
    B: Byte;
    MyEnum: TMyEnum; // Don't define ad-hoc type here because of limitation in RTTI
    MySet: TMySet;
    MyArr: array of integer;
    MyArrStr: array of string;
    MyArrTime: array of TDateTime;
  end;

Read Pascal Objects from Document by using the Object to Document Mapper

As for writing, there is also an adequate method for reading a document. The constructor TFirestoreDocument.LoadObjectFromDocument is used to transfer a loaded document to an object derived from TFirestoreDocument.

type
  TFirestoreDocument = class(TInterfacedObject, IFirestoreDocument)
  public
    constructor LoadObjectFromDocument(Doc: IFirestoreDocument;
      Options: TOTDMapperOptions = []);
  end;

The optional parameter Options: TOTDMapperOptions can be used to control the behavior in the same way as when writing with the IFirestoreDocument.SaveObjectToDocument method. It is important that the identical settings are used when writing as when reading.

The following code shows how easy an object can be loaded from a document.

type
  TMyFirestoreDocument = class(TFirestoreDocument)
  public
    MyStringField: string;
    MyIntField: integer;
  end;
var
  Docs: IFirestoreDocuments;
  Obj: TMyFirestoreDocument;
begin
  Docs := fDatabase.GetSynchronous(['MyCol','DocId']);
  if Docs.Count = 1 then
  begin
    Obj := TMyFirestoreDocument.LoadObjectFromDocument(Docs.Document(0), GetOptions);
    try
      edtMyStringField.Text := Obj.MyStringField;
      edtMyIntField.Text := Obj.MyIntField.ToString;
    finally
      Obj.Free;
    end;
  end;
end; 

This code originates from the FB4D_Samples Project FSObj2Doc.

On the other hand the FB4D_Samples_VCL project FSObj2Doc_VCL, which goes further in this topic, also shows how this document to object conversion can be integrated in a receive handler of the database listener in a comparable way.

  // Starting the listener to monitor an entire collection
  GetDatabase.SubscribeQuery(TStructuredQuery.CreateForCollection(cDocs),
    OnChangedDocument, OnDeletedDocument);
  GetDatabase.StartListener(OnStopListening, OnError);
  ...

// OnChangedDocument handler
procedure OnChangedDocument(ChangedDoc: IFirestoreDocument);
var
  Doc: TMyFSDoc;
begin
  Doc := TMyFSDoc.LoadObjectFromDocument(ChangedDoc, GetOptions);
  try
    // Evaluate the Doc object
  finally
    Doc.Free;
  end;
end;
Clone this wiki locally