jskeet / dotnet-spanner-nhibernate

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Cloud Spanner Dialect for NHibernate

Google Cloud Spanner dialect and driver for NHibernate.

All supported features have been tested and verified to work with the test configurations. There may be configurations and/or data model variations that have not yet been covered by the tests and that show unexpected behavior. Please report any problems that you might encounter by creating a new issue.

Getting started

Add a reference to the Google.Cloud.Spanner.NHibernate nuget package.

Set the Cloud Spanner NHibernate Dialect and a valid Cloud Spanner connection string in your NHibernate configuration to connect to a Cloud Spanner database using NHibernate:

Configuration = new Configuration().DataBaseIntegration(db =>
{
    db.Dialect<SpannerDialect>();
    db.ConnectionString = "Data Source=projects/MY-PROJECT/instances/MY-INSTANCE/databases/MY-DATABASE";
});

Ready to run Samples

The Google.Cloud.Spanner.NHibernate.Samples project contains a number of ready to run samples.

Follow these simple steps to run a sample:

  1. Clone or download this repository to your local computer.
  2. Open a command prompt of your choice and navigate to the Google.Cloud.Spanner.NHibernate.Samples project folder.
  3. Execute the command dotnet run <SampleName> (Example: dotnet run Quickstart). Execute dotnet run to get a list of available sample names.

Browse the Google.Cloud.Spanner.NHibernate.Samples/Snippets directory to view the source code of each sample.

Example Usage

First set up a .NET development environment for Google Cloud Spanner.

NHibernate Configuration

The following code snippet shows how you can configure NHibernate and the entity mappings entirely in code.

var configuration = new Configuration().DataBaseIntegration(db =>
{
    db.Dialect<SpannerDialect>();
    db.ConnectionString = "Data Source=projects/MY-PROJECT/instances/MY-INSTANCE/databases/MY-DATABASE";
    db.BatchSize = 100;
});

var mapper = new ModelMapper();
mapper.AddMapping<SingerMapping>();
mapper.AddMapping<AlbumMapping>();

var mapping = mapper.CompileMappingForAllExplicitlyAddedEntities();
configuration.AddMapping(mapping);

var sessionFactory = Configuration.BuildSessionFactory();
using var session = sessionFactory.OpenSession();

// Create a new Singer and save it. Note that we don't assign the record a value for the Id column. The id
// is automatically generated by NHibernate when the entity is saved.
var transaction = session.BeginTransaction();
var singer = new Singer
{
    FirstName = "Emilia",
    LastName = "Qillaq"
};
session.Save(singer);
transaction.Commit();

NHibernate Entities and Mapping

public class Singer
{
    public virtual string Id { get; set; }
    public virtual string FirstName { get; set; }
    public virtual string LastName { get; set; }
}

public class SingerMapping : ClassMapping<Singer>
{
    public SingerMapping()
    {
        Table("Singers");
        Persister<SpannerSingleTableEntityPersister>();
        // DynamicUpdate(true) ensures that NHibernate will generate UPDATE statements that only modify the columns
        // of the table that have actually been updated.
        DynamicUpdate(true);
        
        Id(x => x.Id, m =>
        {
            // The UUIDHexGeneratorDef automatically generates and assigns a UUID as the primary key value of a new
            // entity. The value is not generated by Cloud Spanner.
            m.Generator(new UUIDHexGeneratorDef());
            m.Column(c =>
            {
                c.NotNullable(true);
                c.Length(36);
            });
        });
        Property(x => x.FirstName, m => m.Length(200));
        Property(x => x.LastName, m =>
        {
            m.Length(200);
            m.NotNullable(true);
        });
        Bag(x => x.Albums, c =>
        {
            // Always mark the collection end of a one-to-many relationship as Inverse(true).
            // This will ensure that NHibernate will always first insert the parent record (the Singer)
            // before the child record (the Album). This reduces the number of DML statements that are
            // needed for a one-to-many relationship significantly.
            c.Inverse(true);
            c.Key(k => k.Column("SingerId"));
        }, r => r.OneToMany());
    }
}

public class Album
{
    public virtual string Id { get; set; }
    public virtual Singer Singer { get; set; }
    public virtual string Title { get; set; }
}

public class AlbumMapping : ClassMapping<Album>
{
    public AlbumMapping()
    {
        Table("Albums");
        Persister<SpannerSingleTableEntityPersister>();
        DynamicUpdate(true);
        ManyToOne(x => x.Singer, m =>
        {
            m.Column(c =>
            {
                c.Name("SingerId");
                c.NotNullable(true);
                c.Length(36);
            });
            m.ForeignKey("FK_Albums_Singers");
        });
        Property(x => x.Title, m =>
        {
            m.NotNullable(true);
            m.Length(100);
            m.Index("Idx_Albums_Title");
        });
    }
}

Generate Data Model from Entities

NHibernate supports automatically generating the data model from source code. The Cloud Spanner dialect also supports this, but requires the usage of the specific SpannerSchemaExport for this purpose. Automatically generating the data model is only recommended for development purposes, as it will drop the existing schema and recreate every time it is executed. For this reason, it is also recommended that this feature is mainly used when developing and testing an application against the Cloud Spanner emulator, as dropping and recreating an entire schema can take a long time on Cloud Spanner.

The following code snippet shows how to automatically drop and recreate the schema.

var configuration = new Configuration().DataBaseIntegration(db =>
{
    db.Dialect<SpannerDialect>();
    db.ConnectionString = "Data Source=projects/MY-PROJECT/instances/MY-INSTANCE/databases/MY-DATABASE";
    db.BatchSize = 100;
});

var mapper = new ModelMapper();
mapper.AddMapping<SingerMapping>();
mapper.AddMapping<AlbumMapping>();

var mapping = mapper.CompileMappingForAllExplicitlyAddedEntities();
configuration.AddMapping(mapping);

var sessionFactory = Configuration.BuildSessionFactory();
using var session = sessionFactory.OpenSession();

// Create a SpannerSchemaExporter and use this to drop and re-create the database schema from the entity
// model. The mapping model must include all metadata for the schema, such as which columns are not-nullable,
// the length of STRING columns, etc.
// Tables are created in the order that they are added to the mapping. Tables are dropped in the opposite
// order. This means that if the model includes interleaved tables, the parent table must be added to the
// mapping BEFORE the child table.
var exporter = new SpannerSchemaExport(configuration.Configuration);

// This will automatically execute a drop-and-recreate script and print the statements that are executed to
// the console (StdOut).
exporter.Create(true /*useStdOut*/, true /*execute*/);

Type Mapping

The table below shows the mapping of Cloud Spanner data types to Clr types. It is recommended to use the default type mapping, unless you know that the values in the database will never exceed the maximum range of another chosen Clr type. Failure to do so for numeric types will lead to an overflow exception for INT64 and NUMERIC types, and to silent loss of precision for FLOAT64 types.

Non-nullable primitive types can be replaced by the corresponding nullable type. That is, bool? can be used instead of bool etc.

Spanner Type Clr Type
BOOL bool
BYTES byte[]
STRING string
INT64 long
FLOAT64 double
NUMERIC SpannerNumeric
DATE SpannerDate
TIMESTAMP DateTime
JSON SpannerJson

Array types must be mapped to the specific SpannerArray types.

Array Type Clr Type
ARRAY<BOOL> SpannerBoolArray
ARRAY<BYTES> SpannerBytesArray
ARRAY<STRING> SpannerStringArray
ARRAY<INT64> SpannerInt64Array
ARRAY<FLOAT64> SpannerFloat64Array
ARRAY<NUMERIC> SpannerNumericArray
ARRAY<DATE> SpannerDateArray
ARRAY<TIMESTAMP> SpannerTimestampArray
ARRAY<JSON> SpannerJsonArray

Specific Cloud Spanner Features

Cloud Spanner has some specific features that are not supported by other relational databases. Using these with NHibernate requires you to include some additional configuration or mapping in your code.

Interleaved Tables

Interleaved tables define a parent-child relationship between two tables where the rows of the child table are physically stored together with the parent rows.

Use the InterleavedTableForeignKey.InterleaveInParent string constant as the name of the foreign key for a many-to-one mapping that should be generated as an INTERLEAVE IN PARENT relationship instead of a traditional FOREIGN KEY. Interleaved child tables must always include all the columns of the parent table in the primary key definition. This means that the id mapping in NHibernate must include multiple columns, where one of the columns is also a many-to-one relationship with the parent table.

Mapping example:

// The primary key of the table Tracks consists of the columns (Id, TrackNumber). The Id column also
// references a row in the Albums table (the parent table).
ComponentAsId(x => x.TrackIdentifier, m =>
{
    m.ManyToOne(id => id.Album, albumMapper =>
    {
        albumMapper.Column(c =>
        {
            c.Name("Id");
            c.NotNullable(true);
            c.Length(36);
        });
        albumMapper.UniqueKey("Idx_Tracks_AlbumId_Title");
        albumMapper.ForeignKey(InterleavedTableForeignKey.InterleaveInParent);
    });
    m.Property(id => id.TrackNumber, propertyMapper => propertyMapper.NotNullable(true));
});

Identifier example:

[Serializable]
public class TrackIdentifier
{
    public virtual Album Album { get; private set; }
    public virtual long TrackNumber { get; private set; }
}

Commit Timestamps

Cloud Spanner can write the commit timestamp of a transaction to a column in a table. This can be used to keep track of the creation and/or last update time of a row.

Use the SpannerCommitTimestampSqlType to indicate that the data type of a column should include the option to set the value to the commit timestamp of the transaction. Set the default value of the column to PENDING_COMMIT_TIMESTAMP() to instruct the Cloud Spanner NHibernate driver to fill the column with the commit timestamp when the entity is inserted/updated.

Example:

// Using the SpannerSingleTableEntityPersister is required for the commit timestamp feature.
Persister<SpannerSingleTableEntityPersister>();
Property(x => x.CreatedAt, m =>
{
    // The following prevents NHibernate from assigning a value to this property when the entity is updated.
    // This might seem counter-intuitive, as we want this value to be filled during inserts. This is however
    // correct, as we don't want NHibernate to assign a value to the column in the INSERT statement, and
    // instead we want the value to be assigned its default value.
    m.Insert(false);
    m.Column(c =>
    {
        // Cloud Spanner currently does not support default values for columns. This mapping is therefore
        // picked up by the SpannerEntityPersister, which will assign the value to the column when a new
        // record is inserted.
        c.Default("PENDING_COMMIT_TIMESTAMP()");
        // This ensures that `OPTIONS (allow_commit_timestamp=true)` is added to the column definition.
        c.SqlType(SpannerCommitTimestampSqlType.NotNullInstance);
    });
});
Property(x => x.LastUpdatedAt, m =>
{
    // The following prevents that NHibernate assigns a value to this property when the entity is updated.
    // This might seem counter-intuitive, as we want this value to be modified during updates. This is
    // however correct, as we don't want NHibernate to assign a value to the column in the UPDATE statement,
    // and instead we want the value to be assigned its default value when it is updated.
    m.Update(false);
    m.Column(c =>
    {
        // Cloud Spanner currently does not support default values for columns. This mapping is therefore
        // picked up by the SpannerEntityPersister, which will assign the value to the column when a
        // record is updated.
        c.Default("PENDING_COMMIT_TIMESTAMP()");
        // This ensures that `OPTIONS (allow_commit_timestamp=true)` is added to the column definition.
        c.SqlType(SpannerCommitTimestampSqlType.NullableInstance);
    });
});

Generated Columns

Cloud Spanner supports generated columns that are calculated using a deterministic expression based on other columns in the same table. These columns may not be updated by client applications. To prevent NHibernate to write values to these columns, they must be marked with Generated(PropertyGeneration.Always).

Example:

Property(x => x.FullName, m =>
{
    m.Generated(PropertyGeneration.Always);
});

Note that generated columns will be read back by NHibernate during any transaction that inserts or updates an entity that has at least one generated column. This has a couple of drawbacks:

  1. Any INSERT/UPDATE statement will automatically be followed by a SELECT statement to read back the generated value, even if the value is not needed by the application.
  2. Generated columns do not work with transactions that use Mutations instead of DML, as modifications that are executed as mutations cannot be read back during the transaction.

One possible workaround for both the above is to define the column as a generated column in your database, but not mark it as generated in the NHibernate mapping. Instead the column should be marked as not-insertable and not-updatable to prevent NHibernate from writing a value to the column. The value will still be generated by Cloud Spanner, and if the generated value is needed by the application, a call to session.Refresh(entity) will read the value from the database.

Example:

// Mapping
Property(x => x.FullName, m =>
{
    m.Insert(false);
    m.Update(false);
});

// Usage
var singer = new Singer { .. };
session.Save(singer);
// This will manually reload all properties from the database, including the generated FullName column.
session.Refresh(singer);

Mutations

Cloud Spanner can use Mutations instead of DML to modify data. Mutations can be faster than DML, but Mutations do not support read-your-writes. It is recommended to read https://cloud.google.com/spanner/docs/dml-versus-mutations if you are not familiar with the differences between Mutations and DML in Cloud Spanner.

The Cloud Spanner NHibernate driver supports executing transactions that will use Mutations instead of DML. These transactions do not support read-your-writes. Any modifications will only be readable after the transaction has committed. Manually flushing the session during a transaction will also not send any modifications to Cloud Spanner.

using var session = sessionFactory.OpenSession();
using var transactionWithMutations = session.BeginTransaction(MutationUsage.Always);
singer = session.Load<Singer>(singerId);
singer.LastName = "Yates-Fish";

// Committing the transaction will send the Mutation to Cloud Spanner and the updated value should be visible.
transactionWithMutations.Commit();
singersWithLastNameYatesFish = session.Query<Singer>().Where(s => s.LastName.Equals("Yates-Fish")).ToList();

Limitations

Generated Values for Primary Keys

Cloud Spanner does not support sequences, identity columns, or other value generators in the database that will generate a unique value that could be used as a primary key value. Instead, the best option is to use a client side Guid generator for a primary key if your table does not contain a natural primary key. Always make sure not to use a monotonically increasing value as the first part of the primary key, as this will cause all inserts to happen at the end of the key space. Cloud Spanner divides data among servers using key ranges, and using a monotonically increasing primary key value will cause one server to receive all inserts. See https://cloud.google.com/spanner/docs/schema-design#primary-key-prevent-hotspots for more information.

Default Values

Cloud Spanner does not support default values for columns. The SpannerSingleTableEntityPersister is however able to simulate this. See the example above for commit timestamps that use this feature.

Performance Recommendations

NHibernate supports a wide range of different configurations and mappings. Some of these can lead to a large number of DML statements being executed when only one entity is updated, or can cause queries to be generated that need to join multiple tables. The following list contains recommendations for how to use NHibernate as efficiently as possible with Cloud Spanner.

Feature Recommendation
Transactions If possible, avoid using read/write transactions for transactions that only read data. Use a read-only transaction to ensure that all reads use the same consistent view of your data, or execute the read operations without a transaction to let each read see the most recent version of the data at the moment the operation is executed.
Batching The Cloud Spanner driver will set adonet.batch_size to 100 to enable batching by default. The recommended value is between 20 and 200. The Cloud Spanner driver will translate ADO.NET batches into Batch DML requests for Cloud Spanner. Setting this value to 0 will disable batching and have a negative impact on performance.
Dynamic Update Set dynamic-update=true for all entity mappings. This ensures that NHibernate will only update those columns that have actually been modified.
Many-to-Many Avoid ManyToMany collections. Instead, define an entity for the relationship and define many-to-one / one-to-many mappings for each side. See the CollectionMapping sample.
One-to-Many Make sure that OneToMany collections are marked with Inverse(true). See the SingerMapping.Albums mapping for an example.
Generated Properties Disable generated properties when executing transactions using mutations. Also consider disabling generated properties altogether in the NHibernate mapping, and replacing the property generation with a column that is not insertable and updateable.

About

License:Apache License 2.0


Languages

Language:C# 98.5%Language:Shell 1.5%Language:Batchfile 0.0%