java/gust/backend/driver/spanner/SpannerGeneratedDDL.java
/*
* Copyright © 2020, The Gust Framework Authors. All rights reserved.
*
* The Gust/Elide framework and tools, and all associated source or object computer code, except where otherwise noted,
* are licensed under the Zero Prosperity license, which is enclosed in this repository, in the file LICENSE.txt. Use of
* this code in object or source form requires and implies consent and agreement to that license in principle and
* practice. Source or object code not listing this header, or unless specified otherwise, remain the property of
* Elide LLC and its suppliers, if any. The intellectual and technical concepts contained herein are proprietary to
* Elide LLC and its suppliers and may be covered by U.S. and Foreign Patents, or patents in process, and are protected
* by trade secret and copyright law. Dissemination of this information, or reproduction of this material, in any form,
* is strictly forbidden except in adherence with assigned license requirements.
*/
package gust.backend.driver.spanner;
import com.google.cloud.spanner.Type;
import com.google.protobuf.Message;
import com.google.protobuf.Timestamp;
import tools.elide.core.SpannerFieldOptions;
import javax.annotation.Nonnull;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.ThreadSafe;
import java.util.*;
import java.util.stream.Collectors;
import static com.google.protobuf.Descriptors.Descriptor;
import static com.google.protobuf.Descriptors.FieldDescriptor;
import static gust.backend.driver.spanner.SpannerUtil.*;
import static gust.backend.model.ModelMetadata.*;
import static java.lang.String.format;
import static java.lang.String.join;
/** Container for generated schema-driven Spanner DDL. */
@Immutable
@ThreadSafe
public final class SpannerGeneratedDDL {
/** Represents a DDL statement structure in code that can be rendered down to a string. */
public interface RenderableStatement {
/**
* Render this statement into a String buffer.
*
* @return Rendered statement.
*/
@Nonnull StringBuilder render();
}
/** Sort direction settings which can apply to columns. */
public enum SortDirection {
/** Sort values in the column in ascending order. This is the default value. */
ASC,
/** Sort values in the column in descending order. */
DESC
}
/** Specifies options for reference action propagation (i.e. on-delete or on-update). */
public enum PropagatedAction {
/** Take no action. This is the default value. */
NO_ACTION,
/** Cascade changes on delete or update. */
CASCADE
}
/** Specifies a generic table constraint to include in a DDL statement. */
public static final class TableConstraint implements RenderableStatement {
final @Nonnull String name;
final @Nonnull String expression;
/**
* Private constructor for a table constraint specification.
*
* @param name Name of the table constraint.
* @param expression Expression to use as a constraint.
*/
private TableConstraint(@Nonnull String name, @Nonnull String expression) {
this.name = name;
this.expression = expression;
}
/**
* Spawn a table constraint at the provided name, enforcing the provided expression.
*
* @param name Name of the constraint to enclose in the DDL statement.
* @param expression Expression to enforce as a table constraint.
* @return Table constraint specification.
*/
public static @Nonnull TableConstraint named(@Nonnull String name,
@Nonnull String expression) {
return new TableConstraint(name, expression);
}
/** @inheritDoc */
@Override
public @Nonnull StringBuilder render() {
return (new StringBuilder()).append(format(
"CONSTRAINT %s CHECK ( %s )",
this.name,
this.expression
));
}
}
/** Specify a parent table against which this table is interleaved. */
public static final class InterleaveTarget implements RenderableStatement {
final @Nonnull String parent;
final @Nonnull Optional<PropagatedAction> action;
/**
* Private constructor for an interleave target for a Spanner table.
*
* @param parent Parent table where we should interleave this table.
* @param action Action to take, if any, when deletes or changes happen in the parent table.
*/
private InterleaveTarget(@Nonnull String parent,
@Nonnull Optional<PropagatedAction> action) {
this.parent = parent;
this.action = action;
}
/**
* Generate an interleave target specification for the provided parent table name.
*
* @see #forParent(String, Optional) To pass a propagation action.
* @param parent Parent table where we should interleave a given table.
* @return Interleave target specification.
*/
public static @Nonnull InterleaveTarget forParent(@Nonnull String parent) {
return forParent(parent, Optional.empty());
}
/**
* Generate an interleave target specification for the provided parent table name, optionally applying the
* provided propagation action.
*
* @param parent Parent table where we should interleave a given table.
* @param action Action to take, if any, when parent rows change that should affect the child table.
* @return Interleave target specification.
*/
public static @Nonnull InterleaveTarget forParent(@Nonnull String parent,
@Nonnull Optional<PropagatedAction> action) {
return new InterleaveTarget(parent, action);
}
/** @inheritDoc */
@Override
public @Nonnull StringBuilder render() {
var buf = new StringBuilder(format(
"INTERLEAVE IN PARENT %s",
this.parent
));
if (this.action.isPresent()) {
buf.append(" ");
buf.append(format(
"ON DELETE %s",
this.action.get().name()
));
}
return buf;
}
}
/** Specifies an individual field as part of a DDL statement. */
private static final class ColumnSpec implements RenderableStatement {
final @Nonnull String name;
final @Nonnull Type type;
final @Nonnull Integer length;
final @Nonnull FieldDescriptor field;
final @Nonnull Boolean nonnull;
final @Nonnull Optional<String> expression;
final @Nonnull Boolean expressionStored;
final @Nonnull Boolean allowCommitTimestamp;
/**
* Private constructor.
*
* @param name Column name in Spanner.
* @param type Type specification in Spanner.
* @param length Length for string fields, or `0`.
* @param nonnull Whether to mark the field as non-null.
* @param allowCommitTimestamp Whether to fill this column with the commit timestamp.
* @param field Model field that spawned this column specification.
*/
ColumnSpec(@Nonnull String name,
@Nonnull Type type,
@Nonnull Integer length,
@Nonnull Optional<Boolean> nonnull,
@Nonnull Optional<String> expression,
@Nonnull Optional<Boolean> expressionStored,
@Nonnull Optional<Boolean> allowCommitTimestamp,
@Nonnull FieldDescriptor field) {
this.name = name;
this.type = type;
this.length = length;
this.nonnull = nonnull.orElse(false);
this.expression = expression;
this.expressionStored = expressionStored.orElse(false);
this.allowCommitTimestamp = allowCommitTimestamp.orElse(false);
this.field = field;
}
/**
* Create a column spec for the provided field information, considering any active driver settings.
*
* @param fieldPointer Pointer to the field we should consider.
* @param settings Active set of Spanner driver settings.
* @return Spawned column corresponding to the provided field.
*/
static @Nonnull ColumnSpec columnSpecForField(@Nonnull FieldPointer fieldPointer,
@Nonnull SpannerDriverSettings settings) {
var columnOpts = columnOpts(fieldPointer);
var spannerOpts = spannerOpts(fieldPointer);
var fieldOpts = fieldOpts(fieldPointer);
var fieldName = resolveColumnName(fieldPointer, spannerOpts, columnOpts, settings);
var fieldType = resolveColumnType(fieldPointer, spannerOpts, columnOpts, settings);
Type innerType = fieldPointer.getField().isRepeated() ?
fieldType.getArrayElementType() :
fieldType;
var fieldSize = innerType.getCode() == Type.Code.STRING || innerType.getCode() == Type.Code.BYTES ?
resolveColumnSize(fieldPointer.getField(), spannerOpts, columnOpts, settings) :
-1;
// resolve spanner opts or defaults
var resolvedSpannerOpts = spannerOpts.orElse(SpannerFieldOptions.getDefaultInstance());
var expression = resolvedSpannerOpts.getExpression().length() > 0 ?
Optional.of(resolvedSpannerOpts.getExpression()) : Optional.<String>empty();
var commitUpdate = false;
var protoType = fieldPointer.getField().getType();
if (fieldOpts.isPresent() && fieldOpts.get().getStampUpdate()) {
switch (protoType) {
case STRING:
case UINT64:
case FIXED64:
commitUpdate = true;
break;
case MESSAGE:
if (fieldPointer.getField().getMessageType().getFullName().equals(
Timestamp.getDescriptor().getFullName())) {
commitUpdate = true; // we can decode from a `Timestamp` record
}
break;
default:
// any other field type represents an illegal state
throw new IllegalStateException(format(
"Cannot place `commit_timestamp` in field of type '%s'", protoType.name()));
}
}
return new ColumnSpec(
fieldName,
fieldType,
fieldSize,
Optional.of(resolvedSpannerOpts.getNonnull()),
expression,
Optional.of(resolvedSpannerOpts.getStored()),
Optional.of(commitUpdate),
fieldPointer.getField()
);
}
/**
* Create a column spec for the provided model key field, considering any active driver settings.
*
* @param model Model schema for the object or key record.
* @param keyField Primary key field pre-resolved for a given Spanner table.
* @param settings Active Spanner driver settings.
* @return Spawned primary key column corresponding to the provided model key.
*/
static @Nonnull ColumnSpec columnSpecForKey(@Nonnull Descriptor model,
@Nonnull FieldPointer keyField,
@Nonnull SpannerDriverSettings settings) {
var idField = idField(model).orElseThrow();
var keyName = resolveKeyColumn(idField, settings);
var keyType = resolveKeyType(idField);
var spannerOpts = spannerOpts(idField);
var columnOpts = columnOpts(idField);
int columnSize = -1;
if (keyType.getCode() == Type.Code.STRING ||
keyType.getCode() == Type.Code.BYTES) {
columnSize = resolveColumnSize(keyField.getField(), spannerOpts, columnOpts, settings);
}
return new ColumnSpec(
keyName,
keyType,
columnSize,
Optional.of(true), // primary keys are always set to `NOT NULL`.
Optional.empty(), // primary keys do not support expressions
Optional.empty(),
Optional.empty(), // primary keys cannot be set to the commit timestamp
keyField.getField()
);
}
/**
* Render this column spec into a definition statement, suitable for use when creating a table.
*
* @return Rendered column spec statement.
*/
@Override
public @Nonnull StringBuilder render() {
// prepare field statement
var buf = new StringBuilder();
// calculate field type designation first
String fieldType;
Type.Code innerType = this.field.isRepeated() ?
this.type.getArrayElementType().getCode() :
this.type.getCode();
String innerTypeSpec;
if (innerType == Type.Code.STRING || innerType == Type.Code.BYTES) {
innerTypeSpec = format(
"%s(%s)",
innerType.name(),
this.length
);
} else {
innerTypeSpec = innerType.name();
}
if (this.type.getCode() == Type.Code.ARRAY) {
// it's a repeated field
fieldType = format(
"ARRAY<%s>",
innerTypeSpec
);
} else {
// it's a singular field. make sure to cover the special case for strings.
fieldType = innerTypeSpec;
}
buf.append(format(
"%s %s",
this.name,
fieldType
));
// prepare field options
var optionsBuffer = new ArrayList<String>();
// consider NONNULL
if (this.nonnull) {
optionsBuffer.add("NOT NULL");
}
// consider expressions
if (this.expression.isPresent()) {
optionsBuffer.add(format("AS ( %s )", this.expression.get()));
if (this.expressionStored)
optionsBuffer.add("STORED");
}
// consider options
if (this.allowCommitTimestamp) {
optionsBuffer.add("OPTIONS allow_commit_timestamp = true");
}
if (!optionsBuffer.isEmpty()) {
buf.append(" ");
buf.append(join(" ", optionsBuffer));
}
return buf;
}
}
/**
* Build properties for a generated Spanner table DDL statement, based on a given model instance as a base for
* configuring the table name (via annotations / calculated defaults) and set of typed Spanner value columns.
*
* <p>To build the actual DDL statement, fill out the builder, build it, and then ask the resulting object for the
* DDL as a string.</p>
*/
@SuppressWarnings("unused")
public static final class Builder {
/** Base model on which this builder will operate. Immutable. */
final @Nonnull Descriptor model;
/** Active set of driver settings. Immutable. */
final @Nonnull SpannerDriverSettings settings;
/** Immutable: Name of the table in Spanner. */
final @Nonnull String tableName;
/** Immutable: Name of the primary key column. */
final @Nonnull String primaryKey;
/** Immutable: Generated columns in Spanner. */
final @Nonnull List<ColumnSpec> columns;
/** Mutable: Key column sort direction. */
@Nonnull SortDirection keySortDirection;
/** Mutable: List of table constraints. */
@Nonnull Optional<List<TableConstraint>> tableConstraints;
/** Mutable: Optimizer version to apply. */
@Nonnull Optional<Integer> optimizerVersion;
/** Mutable: Version retention period. */
@Nonnull Optional<String> versionRetentionPeriod;
/** Mutable: Table interleave target. */
@Nonnull Optional<InterleaveTarget> interleaveTarget;
/**
* Package-private constructor for a builder.
*
* @see SpannerGeneratedDDL#generateTableDDL(Descriptor, SpannerDriverSettings) to spawn one of
* these from regular library or application code.
* @param model Descriptor for the model we are building against.
* @param primaryKey Primary key field name to use for this table by default.
* @param tableName Resolved table name to use for this table.
* @param defaultColumns Default set of columns to use for this table.
* @param settings Active driver settings to apply/consider.
*/
Builder(@Nonnull Descriptor model,
@Nonnull String tableName,
@Nonnull String primaryKey,
@Nonnull List<ColumnSpec> defaultColumns,
@Nonnull SpannerDriverSettings settings) {
this.model = model;
this.tableName = tableName;
this.settings = settings;
this.columns = defaultColumns;
this.primaryKey = primaryKey;
this.keySortDirection = SortDirection.ASC;
this.tableConstraints = Optional.empty();
this.optimizerVersion = Optional.empty();
this.versionRetentionPeriod = Optional.empty();
this.interleaveTarget = Optional.empty();
}
/**
* Render column definition statements for a final DDL table create statement.
*
* @return Column definition statements, stacked in a buffer.
*/
@Nonnull String renderColumnStatements() {
return this.columns.stream()
.map(ColumnSpec::render)
.collect(Collectors.joining(", "));
}
/**
* Render table-level constraint statements for a final DDL table create statement.
*
* @return Any applicable rendered table constraints.
*/
@Nonnull String renderConstraintStatements() {
return this.tableConstraints.map(constraints -> constraints
.stream()
.map(TableConstraint::render)
.collect(Collectors.joining(", ")))
.orElse("");
}
/**
* Render inner statements in the CREATE TABLE DDL statement, including columns and constraints, as applicable.
* If no constraints are present, we simply return the column definitions alone.
*
* @return Rendered definitions of columns and table constraints.
*/
@Nonnull String renderColumnStatementsAndConstraints() {
var columnList = renderColumnStatements();
if (this.tableConstraints.isPresent()) {
var constraints = renderConstraintStatements();
return format("%s, %s", columnList, constraints);
}
return columnList;
}
/**
* Render the primary key specification for a final DDL table create statement.
*
* @return Rendered primary key specification.
*/
@Nonnull String renderPrimaryKey() {
return format(
"%s %s",
this.primaryKey,
this.keySortDirection.name()
);
}
/**
* Render the prepared DDL statement details into a statement string which can be passed to Spanner.
*
* @return Rendered DDL statement, according to local object settings.
*/
@Nonnull StringBuilder renderCreateDDLStatement() {
var builder = new StringBuilder();
var buf = new ArrayList<StringBuilder>();
buf.add(new StringBuilder(format(
"CREATE TABLE %s (%s) PRIMARY KEY (%s)",
this.tableName,
this.renderColumnStatementsAndConstraints(),
this.renderPrimaryKey()
)));
// add interleave target statement, if specified
this.interleaveTarget.ifPresent(target -> buf.add(target.render()));
builder.append(join(", ", buf));
return builder;
}
/**
* Collapse the builder into an immutable DDL statement container
*
* @return Immutable DDL statement container.
*/
public @Nonnull SpannerGeneratedDDL build() {
var fields = forEachField(
model,
Optional.of(onlySpannerEligibleFields(settings))
).map((fieldPointer) ->
ColumnSpec.columnSpecForField(fieldPointer, settings)
).collect(Collectors.toUnmodifiableList());
return new SpannerGeneratedDDL(
tableName,
fields,
model,
renderCreateDDLStatement()
);
}
// -- Builder API: Getters -- //
/** @return Model descriptor this builder wraps. */
public @Nonnull Descriptor getModel() {
return model;
}
/** @return Active Spanner driver settings. */
public @Nonnull SpannerDriverSettings getSettings() {
return settings;
}
/** @return Generated or resolved Spanner table name. */
public @Nonnull String getTableName() {
return tableName;
}
/** @return Primary key column for this model/table. */
public @Nonnull String getPrimaryKey() {
return primaryKey;
}
/** @return Set of generated columns for this model in Spanner. */
public @Nonnull List<ColumnSpec> getColumns() {
return columns;
}
/** @return Primary key column sort direction. */
public @Nonnull SortDirection getKeySortDirection() {
return keySortDirection;
}
/** @return Set of constraints to apply to this table. */
public @Nonnull Optional<List<TableConstraint>> getTableConstraints() {
return tableConstraints;
}
/** @return Optimizer version to set for this table. */
public @Nonnull Optional<Integer> getOptimizerVersion() {
return optimizerVersion;
}
/** @return Data versioning retention period to set for this table. */
public @Nonnull Optional<String> getVersionRetentionPeriod() {
return versionRetentionPeriod;
}
/** @return Parent interleaving target for this table. */
public @Nonnull Optional<InterleaveTarget> getInterleaveTarget() {
return interleaveTarget;
}
// -- Builder API: Setters -- //
/**
* Set the sort direction for the primary key column in this table.
*
* @param keySortDirection Key column sort direction.
* @return Self, for chained calls to the builder.
*/
public @Nonnull Builder setKeySortDirection(@Nonnull SortDirection keySortDirection) {
this.keySortDirection = keySortDirection;
return this;
}
/**
* Set, or clear, the set of table constraints added to this table.
*
* @param tableConstraints Desired table constraints to set or clear, as applicable.
* @return Self, for chained calls to the builder.
*/
public @Nonnull Builder setTableConstraints(@Nonnull Optional<List<TableConstraint>> tableConstraints) {
this.tableConstraints = tableConstraints;
return this;
}
/**
* Set, or clear, the optimizer version to apply when creating this table.
*
* @param optimizerVersion Desired optimizer version to apply, as applicable.
* @return Self, for chained calls to the builder.
*/
public @Nonnull Builder setOptimizerVersion(@Nonnull Optional<Integer> optimizerVersion) {
this.optimizerVersion = optimizerVersion;
return this;
}
/**
* Set, or clear, the data versioning retention period for this table.
*
* @param versionRetentionPeriod Desired data versioning retention period, as applicable.
* @return Self, for chained calls to the builder.
*/
public @Nonnull Builder setVersionRetentionPeriod(@Nonnull Optional<String> versionRetentionPeriod) {
this.versionRetentionPeriod = versionRetentionPeriod;
return this;
}
/**
* Set, or clear, the parent interleave target for this table.
*
* @param interleaveTarget Desired parent interleave target, as applicable.
* @return Self, for chained calls to the builder.
*/
public @Nonnull Builder setInterleaveTarget(@Nonnull Optional<InterleaveTarget> interleaveTarget) {
this.interleaveTarget = interleaveTarget;
return this;
}
}
/** Model that relates to this generated statement. */
private final @Nonnull Descriptor model;
/** Resolved name of the table. */
private final @Nonnull String tableName;
/** Set of generated columns determined to be part of this table. */
private final @Nonnull List<ColumnSpec> columns;
/** Holds the generated query in a string buffer. */
private final @Nonnull StringBuilder generatedStatement;
/**
* Private constructor.
*
* @param tableName Name of the generated table.
* @param columns Generated set of columns.
* @param model Model this table corresponds to.
* @param generatedStatement Rendered DDL statement.
*/
private SpannerGeneratedDDL(@Nonnull String tableName,
@Nonnull List<ColumnSpec> columns,
@Nonnull Descriptor model,
@Nonnull StringBuilder generatedStatement) {
this.tableName = tableName;
this.columns = columns;
this.generatedStatement = generatedStatement;
this.model = model;
}
/**
* Given a model definition, produce a generated DDL statement which creates a backing table in Spanner implementing
* that model's properties. This method variant operates from a full model instance.
*
* <p>This method offers no ability to control driver settings. See below if you need alternatives.</p>
*
* @see #generateTableDDL(Message, Optional) For control over driver settings, optionally.
* @param instance Model instance to generate a table statement for.
* @return Generated DDL statement object.
*/
public static @Nonnull SpannerGeneratedDDL.Builder generateTableDDL(@Nonnull Message instance) {
return generateTableDDL(instance, Optional.of(SpannerDriverSettings.DEFAULTS));
}
/**
* Given a model definition, produce a generated DDL statement which creates a backing table in Spanner implementing
* that model's properties. This method variant operates from a full model instance.
*
* @param instance Model instance to generate a table statement for.
* @param settings Settings to employ for the driver. These must align at runtime.
* @return Generated DDL statement object.
*/
public static @Nonnull SpannerGeneratedDDL.Builder generateTableDDL(
@Nonnull Message instance,
@Nonnull Optional<SpannerDriverSettings> settings) {
return generateTableDDL(
instance.getDescriptorForType(),
settings.orElse(SpannerDriverSettings.DEFAULTS)
);
}
/**
* Given a model definition, produce a generated DDL statement which creates a backing table in Spanner implementing
* that model's properties.
*
* @param model Model schema to generate a table statement for.
* @return Generated DDL statement object.
*/
public static @Nonnull SpannerGeneratedDDL.Builder generateTableDDL(
@Nonnull Descriptor model,
@Nonnull SpannerDriverSettings settings) {
return new SpannerGeneratedDDL.Builder(
model,
resolveTableName(model),
resolveKeyColumn(idField(model).orElseThrow(), settings),
resolveDefaultColumns(model, settings),
settings
);
}
/**
* Resolve the default calculated set of Spanner columns for a given model structure.
*
* @param model Model to traverse and generate columns for.
* @return Set of generated and type-resolved columns.
*/
public static @Nonnull List<ColumnSpec> resolveDefaultColumns(@Nonnull Descriptor model,
@Nonnull SpannerDriverSettings settings) {
var keyField = keyField(model).orElseThrow();
var fieldSet = new LinkedList<ColumnSpec>();
// first up: generate the column which implements the model's primary key
fieldSet.add(ColumnSpec.columnSpecForKey(
model,
keyField,
settings
));
// next: generate all remaining data columns
forEachField(
model,
Optional.of(onlySpannerEligibleFields(settings))
).filter((fieldPointer) ->
// filter out key fields: we'll handle those separately
!keyField.getField().getFullName().equals(fieldPointer.getField().getFullName())
).map((fieldPointer) ->
ColumnSpec.columnSpecForField(fieldPointer, settings)
).forEach(fieldSet::add);
return Collections.unmodifiableList(fieldSet);
}
// -- Accessors -- //
/** @return Model for which this object generates a table create statement. */
public @Nonnull Descriptor getModel() {
return model;
}
/** @return Resolved name of the table to be created. */
public @Nonnull String getTableName() {
return tableName;
}
/** @return Resolved set of Spanner columns. */
public @Nonnull List<ColumnSpec> getColumns() {
return columns;
}
/** @return Rendered generated DDL statement. */
public @Nonnull StringBuilder getGeneratedStatement() {
return generatedStatement;
}
@Override
public String toString() {
return "SpannerDDL{" +
"model=" + model.getFullName() +
", tableName='" + tableName + '\'' +
", statement=\"" + generatedStatement.toString() +
"\"}";
}
}