Support dialect-specific types

This commit is contained in:
Simon Binder 2024-01-10 23:29:27 +01:00
parent 09c6cf0b4e
commit f25eaf10f2
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
18 changed files with 287 additions and 56 deletions

View File

@ -1,3 +1,4 @@
// #docregion duration
import 'package:drift/drift.dart';
class DurationType implements CustomSqlType<Duration> {
@ -17,3 +18,37 @@ class DurationType implements CustomSqlType<Duration> {
@override
String sqlTypeName(GenerationContext context) => 'interval';
}
// #enddocregion duration
// #docregion fallback
class _FallbackDurationType implements CustomSqlType<Duration> {
const _FallbackDurationType();
@override
String mapToSqlLiteral(Duration dartValue) {
return dartValue.inMicroseconds.toString();
}
@override
Object mapToSqlParameter(Duration dartValue) {
return dartValue.inMicroseconds;
}
@override
Duration read(Object fromSql) {
return Duration(microseconds: fromSql as int);
}
@override
String sqlTypeName(GenerationContext context) {
return 'integer';
}
}
// #enddocregion fallback
const durationType = DialectAwareSqlType<Duration>.via(
fallback: _FallbackDurationType(),
overrides: {
SqlDialect.postgres: DurationType(),
},
);

View File

@ -7,6 +7,8 @@ data:
template: layouts/docs/single
---
{% assign type_snippets = "package:drift_docs/snippets/modular/custom_types/type.dart.excerpt.json" | readString | json_decode %}
Drift's core library is written with sqlite3 as a primary target. This is
reflected in the [SQL types][types] drift supports out of the box - these
types supported by sqlite3 with a few additions that are handled in Dart.
@ -41,7 +43,7 @@ prepared statements and also be read from rows without manual conversions.
In that case, a custom type class to implement `Duration` support for drift would be
added:
{% include "blocks/snippet" snippets = ('package:drift_docs/snippets/modular/custom_types/type.dart.excerpt.json' | readString | json_decode) %}
{% include "blocks/snippet" snippets = type_snippets name = "duration" %}
This type defines the following things:
@ -84,3 +86,25 @@ opening an issue or a discussion describing your use-cases, thanks!
[types]: {{ '../Dart API/tables.md#supported-column-types' | pageUrl }}
[type converters]: {{ '../type_converters.md' | pageUrl }}
## Dialect awareness
When defining custom types for SQL types only supported on some database management systems, your
database will _only_ work with those database systems. For instance, any table using the `DurationType`
defined above will not work with sqlite3 since it uses an `interval` type interpreted as an integer
by sqlite3 - and the `interval xyz microseconds` syntax is not supported by sqlite3 at all.
Starting with drift 2.15, it is possible to define custom types that behave differently depending on
the dialect used.
This can be used to build polyfills for other database systems. First, consider a custom type storing
durations as integers, similar to what a type converter might do:
{% include "blocks/snippet" snippets = type_snippets name = "fallback" %}
By using a `DialectAwareSqlType`, you can automatically use the `interval` type on PostgreSQL databases
while falling back to an integer type on sqlite3 and other databases:
```dart
Column<Duration> get frequency => customType(durationType)
.clientDefault(() => Duration(minutes: 15))();
```

View File

@ -1,6 +1,10 @@
## 2.15.0-dev
- Methods in the query builder API now respect custom types.
- Support `DialectAwareSqlType`, custom types that depend on the dialect of the
active database connection. This can be used to use native types not
supported by drift (like UUIDs) on databases that support it while falling
back to a text type on sqlite3.
- Close wasm databases hosted in workers after the last client disconnects.
## 2.14.1

View File

@ -19,7 +19,7 @@ export 'src/runtime/executor/interceptor.dart';
export 'src/runtime/query_builder/query_builder.dart'
hide CaseWhenExpressionWithBase, BaseCaseWhenExpression;
export 'src/runtime/types/converters.dart';
export 'src/runtime/types/mapping.dart' hide BaseSqlType;
export 'src/runtime/types/mapping.dart' hide BaseSqlType, UserDefinedSqlType;
export 'src/utils/lazy_database.dart';
/// A [ListEquality] instance used by generated drift code for the `==` and

View File

@ -2,7 +2,7 @@
export 'dart:typed_data' show Uint8List;
export 'runtime/types/converters.dart' show TypeConverter, JsonTypeConverter2;
export 'runtime/types/mapping.dart' show DriftAny, CustomSqlType;
export 'runtime/types/mapping.dart' show DriftAny, UserDefinedSqlType;
export 'runtime/query_builder/query_builder.dart' show TableInfo;
export 'dsl/dsl.dart'

View File

@ -2,6 +2,8 @@ import 'package:drift/drift.dart';
import 'package:meta/meta.dart';
import 'package:meta/meta_meta.dart';
import '../runtime/types/mapping.dart';
part 'columns.dart';
part 'database.dart';
part 'table.dart';

View File

@ -205,7 +205,7 @@ abstract class Table extends HasResultSet {
/// For most users, [TypeConverter]s are a more appropriate tool to store
/// custom values in the database.
@protected
ColumnBuilder<T> customType<T extends Object>(CustomSqlType<T> type) =>
ColumnBuilder<T> customType<T extends Object>(UserDefinedSqlType<T> type) =>
_isGenerated();
}

View File

@ -19,7 +19,8 @@ class TypeDescription {
factory TypeDescription.fromDrift(GenerationContext ctx, BaseSqlType type) {
return switch (type) {
DriftSqlType() => TypeDescription(type: type),
CustomSqlType<Object>() =>
CustomSqlType() ||
DialectAwareSqlType() =>
TypeDescription(customTypeName: type.sqlTypeName(ctx)),
};
}

View File

@ -549,7 +549,9 @@ class _CastInSqlExpression<D1 extends Object, D2 extends Object>
DriftSqlType.blob => 'BINARY',
DriftSqlType.dateTime => 'DATETIME',
DriftSqlType.any => '',
CustomSqlType() => targetType.sqlTypeName(context),
CustomSqlType() ||
DialectAwareSqlType() =>
targetType.sqlTypeName(context),
};
} else {
typeName = targetType.sqlTypeName(context);

View File

@ -8,7 +8,7 @@ part of '../query_builder.dart';
final class Variable<T extends Object> extends Expression<T> {
/// The Dart value that will be sent to the database
final T? value;
final CustomSqlType<T>? _customType;
final UserDefinedSqlType<T>? _customType;
// note that we keep the identity hash/equals here because each variable would
// get its own index in sqlite and is thus different.
@ -67,12 +67,7 @@ final class Variable<T extends Object> extends Expression<T> {
/// database engine. For instance, a [DateTime] will me mapped to its unix
/// timestamp.
dynamic mapToSimpleValue(GenerationContext context) {
final type = _customType;
if (value != null && type != null) {
return type.mapToSqlParameter(value!);
} else {
return context.typeMapping.mapToSqlVariable(value);
}
return BaseSqlType.mapToSqlParameter<T>(context, _customType, value);
}
@override
@ -126,7 +121,7 @@ final class Constant<T extends Object> extends Expression<T> {
/// The value that will be converted to an sql literal.
final T? value;
final CustomSqlType<T>? _customType;
final UserDefinedSqlType<T>? _customType;
/// Constructs a new constant (sql literal) holding the [value].
const Constant(this.value, [this._customType]);
@ -142,12 +137,8 @@ final class Constant<T extends Object> extends Expression<T> {
@override
void writeInto(GenerationContext context) {
final type = _customType;
if (value != null && type != null) {
context.buffer.write(type.mapToSqlLiteral(value!));
} else {
context.buffer.write(context.typeMapping.mapToSqlLiteral(value));
}
return context.buffer
.write(BaseSqlType.mapToSqlLiteral(context, _customType, value));
}
@override

View File

@ -27,12 +27,12 @@ final class SqlTypes {
/// [the documentation]: https://drift.simonbinder.eu/docs/getting-started/advanced_dart_tables/#supported-column-types
final bool storeDateTimesAsText;
final SqlDialect _dialect;
/// The [SqlDialect] to consider when mapping values from and to Dart.
final SqlDialect dialect;
/// Creates an [SqlTypes] mapper from the provided options.
@internal
const SqlTypes(this.storeDateTimesAsText,
[this._dialect = SqlDialect.sqlite]);
const SqlTypes(this.storeDateTimesAsText, [this.dialect = SqlDialect.sqlite]);
/// Maps a Dart object to a (possibly simpler) object that can be used as a
/// parameter in raw sql queries.
@ -73,7 +73,7 @@ final class SqlTypes {
}
}
if (dartValue is bool && _dialect == SqlDialect.sqlite) {
if (dartValue is bool && dialect == SqlDialect.sqlite) {
return dartValue ? 1 : 0;
}
@ -91,7 +91,7 @@ final class SqlTypes {
// todo: Inline and remove types in the next major drift version
if (dart is bool) {
if (_dialect == SqlDialect.sqlite) {
if (dialect == SqlDialect.sqlite) {
return dart ? '1' : '0';
} else {
return dart ? 'true' : 'false';
@ -196,6 +196,7 @@ final class SqlTypes {
},
DriftSqlType.any => DriftAny(sqlValue),
CustomSqlType() => type.read(sqlValue),
DialectAwareSqlType() => type.read(this, sqlValue),
} as T;
}
}
@ -273,8 +274,42 @@ final class DriftAny {
sealed class BaseSqlType<T> {
/// Returns a suitable representation of this type in SQL.
String sqlTypeName(GenerationContext context);
static T? read<T extends Object>(
SqlTypes types, BaseSqlType<T> type, Object fromSql) {
return types.read(type, fromSql);
}
static Object? mapToSqlParameter<T extends Object>(
GenerationContext context, BaseSqlType<T>? type, T? value) {
if (value == null) return null;
return switch (type) {
null ||
DriftSqlType<Object>() =>
context.typeMapping.mapToSqlVariable(value),
CustomSqlType<T>() => type.mapToSqlParameter(value),
DialectAwareSqlType<T>() => type.mapToSqlParameter(context, value),
};
}
static String mapToSqlLiteral<T extends Object>(
GenerationContext context, BaseSqlType<T>? type, T? value) {
if (value == null) return 'NULL';
return switch (type) {
null ||
DriftSqlType<Object>() =>
context.typeMapping.mapToSqlLiteral(value),
CustomSqlType<T>() => type.mapToSqlLiteral(value),
DialectAwareSqlType<T>() => type.mapToSqlLiteral(context, value),
};
}
}
@internal
sealed class UserDefinedSqlType<T> implements BaseSqlType<T> {}
/// An enumation of type mappings that are builtin to drift and `drift_dev`.
enum DriftSqlType<T extends Object> implements BaseSqlType<T> {
/// A boolean type, represented as `0` or `1` (int) in SQL.
@ -410,7 +445,7 @@ enum DriftSqlType<T extends Object> implements BaseSqlType<T> {
/// Custom types can also be applied to table columns, see https://drift.simonbinder.eu/docs/sql-api/types/
/// for details.
abstract interface class CustomSqlType<T extends Object>
implements BaseSqlType<T> {
implements UserDefinedSqlType<T> {
/// Interprets the underlying [fromSql] value from the database driver into
/// the Dart representation [T] of this type.
T read(Object fromSql);
@ -423,3 +458,79 @@ abstract interface class CustomSqlType<T extends Object>
/// into SQL queries generated by drift.
String mapToSqlLiteral(T dartValue);
}
/// A [CustomSqlType] with access on the dialect of the database engine when
/// used in queries.
///
/// This can be used to design drift types providing polyfills for types only
/// supported on some databases, for instance by using native `DATE` support
/// on postgres but falling back to a textual representation on sqlite3.
abstract interface class DialectAwareSqlType<T extends Object>
implements UserDefinedSqlType<T> {
/// Creates a [DialectAwareSqlType] that uses the [fallback] type by default,
/// but can apply [overrides] on some database systems.
///
/// For instance, this can be used to create a custom type that stores uuids
/// as `TEXT` on databases with no builtin UUID type, but otherwise uses the
/// native format:
///
/// ```dart
/// class UuidAsTextType implements CustomSqlType<Uuid> { ... }
///
/// const uuidType = DialectAwareSqlType.via(
/// fallback: UuidAsTextType(),
/// overrides: {
/// SqlDialect.postgres: PgTypes.uuid,
/// }
/// );
/// ```
const factory DialectAwareSqlType.via({
required BaseSqlType<T> fallback,
required Map<SqlDialect, BaseSqlType<T>> overrides,
}) = _ByDialectType<T>;
/// Interprets the underlying [fromSql] value from the database driver into
/// the Dart representation [T] of this type.
T read(SqlTypes typeSystem, Object fromSql);
/// Maps the [dartValue] to a value understood by the underlying database
/// driver.
Object mapToSqlParameter(GenerationContext context, T dartValue);
/// Maps the [dartValue] to a SQL snippet that can be embedded as a literal
/// into SQL queries generated by drift.
String mapToSqlLiteral(GenerationContext context, T dartValue);
}
final class _ByDialectType<T extends Object> implements DialectAwareSqlType<T> {
final BaseSqlType<T> fallback;
final Map<SqlDialect, BaseSqlType<T>> overrides;
const _ByDialectType({required this.fallback, required this.overrides});
BaseSqlType<T> _selectType(SqlTypes typeSystem) {
return overrides[typeSystem.dialect] ?? fallback;
}
@override
String mapToSqlLiteral(GenerationContext context, T dartValue) {
return BaseSqlType.mapToSqlLiteral(
context, _selectType(context.typeMapping), dartValue);
}
@override
Object mapToSqlParameter(GenerationContext context, T dartValue) {
return BaseSqlType.mapToSqlParameter(
context, _selectType(context.typeMapping), dartValue)!;
}
@override
T read(SqlTypes typeSystem, Object fromSql) {
return BaseSqlType.read(typeSystem, _selectType(typeSystem), fromSql)!;
}
@override
String sqlTypeName(GenerationContext context) {
return _selectType(context.typeMapping).sqlTypeName(context);
}
}

View File

@ -112,6 +112,12 @@ void main() {
test('creates tables with custom types', () async {
await db.createMigrator().createTable(db.withCustomType);
verify(mockExecutor.runCustom(
'CREATE TABLE IF NOT EXISTS "with_custom_type" ("id" text NOT NULL);',
[]));
when(mockExecutor.dialect).thenReturn(SqlDialect.postgres);
await db.createMigrator().createTable(db.withCustomType);
verify(mockExecutor.runCustom(
'CREATE TABLE IF NOT EXISTS "with_custom_type" ("id" uuid NOT NULL);',
[]));

View File

@ -11,30 +11,33 @@ void main() {
group('in expression', () {
test('variable', () {
final c = Variable<UuidValue>(uuid, const UuidType());
final c = Variable<UuidValue>(uuid, const NativeUuidType());
expect(c.driftSqlType, isA<UuidType>());
expect(c.driftSqlType, isA<NativeUuidType>());
expect(c, generates('?', [uuid]));
});
test('constant', () {
final c = Constant<UuidValue>(uuid, const UuidType());
final c = Constant<UuidValue>(uuid, const NativeUuidType());
expect(c.driftSqlType, isA<UuidType>());
expect(c.driftSqlType, isA<NativeUuidType>());
expect(c, generates("'$uuid'"));
});
test('cast', () {
final cast = Variable('foo').cast<UuidValue>(const UuidType());
final cast = Variable('foo').cast<UuidValue>(const NativeUuidType());
expect(cast.driftSqlType, isA<UuidType>());
expect(cast.driftSqlType, isA<NativeUuidType>());
expect(cast, generates('CAST(? AS uuid)', ['foo']));
});
});
test('for inserts', () async {
final executor = MockExecutor();
final database = TodoDb(executor);
final sqlite3Executor = MockExecutor();
final postgresExecutor = MockExecutor();
when(postgresExecutor.dialect).thenReturn(SqlDialect.postgres);
var database = TodoDb(sqlite3Executor);
addTearDown(database.close);
final uuid = Uuid().v4obj();
@ -42,23 +45,48 @@ void main() {
.into(database.withCustomType)
.insert(WithCustomTypeCompanion.insert(id: uuid));
verify(executor
.runInsert('INSERT INTO "with_custom_type" ("id") VALUES (?)', [uuid]));
verify(sqlite3Executor.runInsert(
'INSERT INTO "with_custom_type" ("id") VALUES (?)', [uuid.toString()]));
database.close();
database = TodoDb(postgresExecutor);
await database
.into(database.withCustomType)
.insert(WithCustomTypeCompanion.insert(id: uuid));
verify(postgresExecutor.runInsert(
r'INSERT INTO "with_custom_type" ("id") VALUES ($1)', [uuid]));
});
test('for selects', () async {
final executor = MockExecutor();
final database = TodoDb(executor);
addTearDown(database.close);
final uuid = Uuid().v4obj();
when(executor.runSelect(any, any)).thenAnswer((_) {
final sqlite3Executor = MockExecutor();
when(sqlite3Executor.runSelect(any, any)).thenAnswer((_) {
return Future.value([
{'id': uuid.toString()}
]);
});
final postgresExecutor = MockExecutor();
when(postgresExecutor.dialect).thenReturn(SqlDialect.postgres);
when(postgresExecutor.runSelect(any, any)).thenAnswer((_) {
return Future.value([
{'id': uuid}
]);
});
var database = TodoDb(sqlite3Executor);
addTearDown(database.close);
final row = await database.withCustomType.all().getSingle();
expect(row.id, uuid);
await database.close();
database = TodoDb(postgresExecutor);
final pgRow = await database.withCustomType.all().getSingle();
expect(pgRow.id, uuid);
});
}

View File

@ -181,11 +181,11 @@ abstract class TodoWithCategoryView extends View {
}
class WithCustomType extends Table {
Column<UuidValue> get id => customType(const UuidType())();
Column<UuidValue> get id => customType(uuidType)();
}
class UuidType implements CustomSqlType<UuidValue> {
const UuidType();
class NativeUuidType implements CustomSqlType<UuidValue> {
const NativeUuidType();
@override
String mapToSqlLiteral(UuidValue dartValue) {
@ -206,6 +206,33 @@ class UuidType implements CustomSqlType<UuidValue> {
String sqlTypeName(GenerationContext context) => 'uuid';
}
class _UuidAsTextType implements CustomSqlType<UuidValue> {
const _UuidAsTextType();
@override
String mapToSqlLiteral(UuidValue dartValue) {
return "'$dartValue'";
}
@override
Object mapToSqlParameter(UuidValue dartValue) {
return dartValue.toString();
}
@override
UuidValue read(Object fromSql) {
return UuidValue.fromString(fromSql as String);
}
@override
String sqlTypeName(GenerationContext context) => 'text';
}
const uuidType = DialectAwareSqlType<UuidValue>.via(
fallback: _UuidAsTextType(),
overrides: {SqlDialect.postgres: NativeUuidType()},
);
@DriftDatabase(
tables: [
TodosTable,

View File

@ -1483,7 +1483,7 @@ class $WithCustomTypeTable extends WithCustomType
@override
late final GeneratedColumn<UuidValue> id = GeneratedColumn<UuidValue>(
'id', aliasedName, false,
type: const UuidType(), requiredDuringInsert: true);
type: uuidType, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns => [id];
@override
@ -1511,7 +1511,7 @@ class $WithCustomTypeTable extends WithCustomType
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return WithCustomTypeData(
id: attachedDatabase.typeMapping
.read(const UuidType(), data['${effectivePrefix}id'])!,
.read(uuidType, data['${effectivePrefix}id'])!,
);
}
@ -1528,7 +1528,7 @@ class WithCustomTypeData extends DataClass
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<UuidValue>(id, const UuidType());
map['id'] = Variable<UuidValue>(id, uuidType);
return map;
}
@ -1609,7 +1609,7 @@ class WithCustomTypeCompanion extends UpdateCompanion<WithCustomTypeData> {
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<UuidValue>(id.value, const UuidType());
map['id'] = Variable<UuidValue>(id.value, uuidType);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);

View File

@ -27,7 +27,7 @@ class KnownDriftTypes {
final InterfaceType tableInfoType;
final InterfaceType driftDatabase;
final InterfaceType driftAccessor;
final InterfaceElement customSqlType;
final InterfaceElement userDefinedSqlType;
final InterfaceElement typeConverter;
final InterfaceElement jsonTypeConverter;
final InterfaceType driftAny;
@ -40,7 +40,7 @@ class KnownDriftTypes {
this.tableIndexType,
this.viewType,
this.tableInfoType,
this.customSqlType,
this.userDefinedSqlType,
this.typeConverter,
this.jsonTypeConverter,
this.driftDatabase,
@ -64,7 +64,7 @@ class KnownDriftTypes {
(exportNamespace.get('TableIndex') as InterfaceElement).thisType,
(exportNamespace.get('View') as InterfaceElement).thisType,
(exportNamespace.get('TableInfo') as InterfaceElement).thisType,
exportNamespace.get('CustomSqlType') as InterfaceElement,
exportNamespace.get('UserDefinedSqlType') as InterfaceElement,
exportNamespace.get('TypeConverter') as InterfaceElement,
exportNamespace.get('JsonTypeConverter2') as InterfaceElement,
dbElement.defaultInstantiation,
@ -84,8 +84,8 @@ class KnownDriftTypes {
return type.asInstanceOf(typeConverter);
}
InterfaceType? asCustomType(DartType type) {
return type.asInstanceOf(customSqlType);
InterfaceType? asUserDefinedType(DartType type) {
return type.asInstanceOf(userDefinedSqlType);
}
/// Converts the given Dart [type] into an instantiation of the

View File

@ -275,7 +275,7 @@ CustomColumnType? readCustomType(
) {
final staticType = dartExpression.staticType;
final asCustomType =
staticType != null ? helper.asCustomType(staticType) : null;
staticType != null ? helper.asUserDefinedType(staticType) : null;
if (asCustomType == null) {
reportError('Not a custom type');

View File

@ -127,8 +127,8 @@ class DriftColumn implements HasType {
}
class CustomColumnType {
/// The Dart expression creating an instance of the `CustomSqlType` responsible
/// for the column.
/// The Dart expression creating an instance of the `UserDefinedType`
/// responsible for the column.
final AnnotatedDartCode expression;
final DartType dartType;