Merge pull request #2521 from BananaMasterz/develop

adding support for MariaDB dialect
This commit is contained in:
Simon Binder 2023-08-03 17:49:57 +02:00 committed by GitHub
commit 29ba50a0ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1157 additions and 207 deletions

View File

@ -166,6 +166,13 @@ jobs:
--health-retries 5
ports:
- 5432:5432
mariadb:
image: mariadb
env:
MARIADB_ROOT_PASSWORD: password
MARIADB_DATABASE: database
ports:
- 3306:3306
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/prepare
@ -173,7 +180,33 @@ jobs:
dart_version: ${{ needs.setup.outputs.dart_version }}
- run: melos bootstrap --no-flutter
working-directory: .
- run: tool/misc_integration_test.sh
- name: Postgres integration tests
working-directory: extras/drift_postgres
run: |
dart pub upgrade
dart test
- name: MariaDB integration tests
working-directory: extras/drift_mariadb
continue-on-error: true
run: |
dart pub upgrade
dart test
- name: Integration test with built_value
working-directory: examples/with_built_value
run: |
dart pub upgrade
dart run build_runner build --delete-conflicting-outputs
- name: Integration test with modular generation
working-directory: examples/modular
run: |
dart pub upgrade
dart run build_runner build --delete-conflicting-outputs
dart run bin/example.dart
- name: Integration test for migrations example
working-directory: examples/migrations_example
run: |
dart pub upgrade
dart test
migration_integration_tests:
name: "Integration tests for migration tooling"

View File

@ -193,6 +193,187 @@ const additionalPostgresKeywords = <String>{
'USER',
};
/// A set of keywords that need to be escaped on mariadb and aren't contained
/// in [baseKeywords].
const additionalMariaDBKeywords = <String>{
'ACCESSIBLE',
'ASENSITIVE',
'AUTO_INCREMENT',
'BIGINT',
'BINARY',
'BLOB',
'BOTH',
'CALL',
'CHANGE',
'CHAR',
'CHARACTER',
'CONDITION',
'CONTINUE',
'CONVERT',
'CURRENT_ROLE',
'CURRENT_USER',
'CURSOR',
'DATABASES',
'DAY_HOUR',
'DAY_MICROSECOND',
'DAY_MINUTE',
'DAY_SECOND',
'DEC',
'DECIMAL',
'DECLARE',
'DELAYED',
'DELETE_DOMAIN_ID',
'DESCRIBE',
'DETERMINISTIC',
'DISTINCTROW',
'DIV',
'DO_DOMAIN_IDS',
'DOUBLE',
'DUAL',
'ELSEIF',
'ENCLOSED',
'ESCAPED',
'EXIT',
'FETCH',
'FLOAT',
'FLOAT4',
'FLOAT8',
'FORCE',
'FULLTEXT',
'GENERAL',
'GRANT',
'HIGH_PRIORITY',
'HOUR_MICROSECOND',
'HOUR_MINUTE',
'HOUR_SECOND',
'IGNORE_DOMAIN_IDS',
'IGNORE_SERVER_IDS',
'INFILE',
'INOUT',
'INSENSITIVE',
'INT',
'INT1',
'INT2',
'INT3',
'INT4',
'INT8',
'INTEGER',
'INTERVAL',
'ITERATE',
'KEYS',
'KILL',
'LEADING',
'LEAVE',
'LINEAR',
'LINES',
'LOAD',
'LOCALTIME',
'LOCALTIMESTAMP',
'LOCK',
'LONG',
'LONGBLOB',
'LONGTEXT',
'LOOP',
'LOW_PRIORITY',
'MASTER_HEARTBEAT_PERIOD',
'MASTER_SSL_VERIFY_SERVER_CERT',
'MAXVALUE',
'MEDIUMBLOB',
'MEDIUMINT',
'MEDIUMTEXT',
'MIDDLEINT',
'MINUTE_MICROSECOND',
'MINUTE_SECOND',
'MOD',
'MODIFIES',
'NO_WRITE_TO_BINLOG',
'NUMERIC',
'OPTIMIZE',
'OPTION',
'OPTIONALLY',
'OUT',
'OUTFILE',
'PAGE_CHECKSUM',
'PARSE_VCOL_EXPR',
'POSITION',
'PRECISION',
'PROCEDURE',
'PURGE',
'READ',
'READS',
'READ_WRITE',
'REAL',
'REF_SYSTEM_ID',
'REPEAT',
'REQUIRE',
'RESIGNAL',
'RETURN',
'RETURNING',
'REVOKE',
'RLIKE',
'ROW_NUMBER',
'SCHEMA',
'SCHEMAS',
'SECOND_MICROSECOND',
'SENSITIVE',
'SEPARATOR',
'SHOW',
'SIGNAL',
'SLOW',
'SMALLINT',
'SPATIAL',
'SPECIFIC',
'SQL',
'SQLEXCEPTION',
'SQLSTATE',
'SQLWARNING',
'SQL_BIG_RESULT',
'SQL_CALC_FOUND_ROWS',
'SQL_SMALL_RESULT',
'SSL',
'STARTING',
'STATS_AUTO_RECALC',
'STATS_PERSISTENT',
'STATS_SAMPLE_PAGES',
'STRAIGHT_JOIN',
'TERMINATED',
'TINYBLOB',
'TINYINT',
'TINYTEXT',
'TRAILING',
'TYPE',
'UNDO',
'UNLOCK',
'UNSIGNED',
'USAGE',
'USE',
'UTC_DATE',
'UTC_TIME',
'UTC_TIMESTAMP',
'VARBINARY',
'VARCHAR',
'VARCHARACTER',
'VARYING',
'WHILE',
'WRITE',
'XOR',
'YEAR_MONTH',
'ZEROFILL',
'BODY',
'ELSIF',
'GOTO',
'HISTORY',
'MINUS',
'PACKAGE',
'PERIOD',
'ROWNUM',
'ROWTYPE',
'SYSDATE',
'SYSTEM',
'SYSTEM_TIME',
'VERSIONING',
};
/// Returns whether [s] is an sql keyword by comparing it to the
/// [sqliteKeywords].
bool isSqliteKeyword(String s) => sqliteKeywords.contains(s.toUpperCase());
@ -208,6 +389,11 @@ String escapeIfNeeded(String s, [SqlDialect dialect = SqlDialect.sqlite]) {
isKeyword |= additionalPostgresKeywords.contains(inUpperCase);
}
if (isKeyword || _notInKeyword.hasMatch(s)) return '"$s"';
if (dialect == SqlDialect.mariadb) {
isKeyword |= additionalMariaDBKeywords.contains(inUpperCase);
}
if (isKeyword || _notInKeyword.hasMatch(s)) return dialect.escape(s);
return s;
}

View File

@ -23,6 +23,8 @@ enum KeyAction {
/// No special action is taken when the parent key is modified or deleted from
/// the database.
///
/// For [SqlDialect.mariadb] this is synonym for [restrict].
noAction,
}
@ -34,7 +36,7 @@ abstract class Column<T extends Object> extends Expression<T> {
/// The (unescaped) name of this column.
///
/// Use [escapedName] to access a name that's escaped in double quotes if
/// Use [escapedNameFor] to access a name that's escaped in double quotes if
/// needed.
String get name;
@ -43,7 +45,14 @@ abstract class Column<T extends Object> extends Expression<T> {
/// In the past, this getter only used to add double-quotes when that is
/// really needed (for instance because [name] is also a reserved keyword).
/// For performance reasons, we unconditionally escape names now.
@Deprecated('Use escapedNameFor with the current dialect')
String get escapedName => '"$name"';
/// [name], but wrapped in double quotes or the DBMS-specific escape
/// identifier.
String escapedNameFor(SqlDialect dialect) {
return dialect.escape(name);
}
}
/// A column that stores int values.

View File

@ -566,10 +566,21 @@ abstract class DatabaseConnectionUser {
/// Used by generated code to expand array variables.
String $expandVar(int start, int amount) {
final buffer = StringBuffer();
final mark = executor.dialect == SqlDialect.postgres ? '@' : '?';
final variableSymbol = switch (executor.dialect) {
SqlDialect.postgres => r'$',
_ => '?',
};
final supportsIndexedParameters =
executor.dialect.supportsIndexedParameters;
for (var x = 0; x < amount; x++) {
buffer.write('$mark${start + x}');
if (supportsIndexedParameters) {
buffer.write('$variableSymbol${start + x}');
} else {
buffer.write(variableSymbol);
}
if (x != amount - 1) {
buffer.write(', ');
}

View File

@ -3,6 +3,12 @@ import 'dart:async' show FutureOr;
import 'package:drift/drift.dart';
import 'package:drift/src/runtime/executor/helpers/results.dart';
String _defaultSavepoint(int depth) => 'SAVEPOINT s$depth';
String _defaultRelease(int depth) => 'RELEASE s$depth';
String _defaultRollbackToSavepoint(int depth) => 'ROLLBACK TO s$depth';
/// An interface that supports sending database queries. Used as a backend for
/// drift.
///
@ -132,6 +138,18 @@ class NoTransactionDelegate extends TransactionDelegate {
/// database engine.
final String rollback;
/// The statement that will create a savepoint for a given depth of a transaction
/// on this database engine.
final String Function(int depth) savepoint;
/// The statement that will release a savepoint for a given depth of a transaction
/// on this database engine.
final String Function(int depth) release;
/// The statement that will perform a rollback to a savepoint for a given depth
/// of a transaction on this database engine.
final String Function(int depth) rollbackToSavepoint;
/// Construct a transaction delegate indicating that native transactions
/// aren't supported and need to be emulated by issuing statements and
/// locking the database.
@ -139,6 +157,9 @@ class NoTransactionDelegate extends TransactionDelegate {
this.start = 'BEGIN TRANSACTION',
this.commit = 'COMMIT TRANSACTION',
this.rollback = 'ROLLBACK TRANSACTION',
this.savepoint = _defaultSavepoint,
this.release = _defaultRelease,
this.rollbackToSavepoint = _defaultRollbackToSavepoint,
});
}

View File

@ -187,9 +187,9 @@ class _StatementBasedTransactionExecutor extends _TransactionExecutor {
_StatementBasedTransactionExecutor.nested(
_StatementBasedTransactionExecutor this._parent, int depth)
: _delegate = _parent._delegate,
_startCommand = 'SAVEPOINT s$depth',
_commitCommand = 'RELEASE s$depth',
_rollbackCommand = 'ROLLBACK TO s$depth',
_startCommand = _parent._delegate.savepoint(depth),
_commitCommand = _parent._delegate.release(depth),
_rollbackCommand = _parent._delegate.rollbackToSavepoint(depth),
super(_parent._db);
@override

View File

@ -156,6 +156,10 @@ abstract class Expression<D extends Object> implements FunctionParameter {
///
/// For an "is in" comparison with values, use [isIn].
Expression<bool> isInExp(List<Expression<D>> expressions) {
if (expressions.isEmpty) {
return Constant(false);
}
return _InExpression(this, expressions, false);
}
@ -164,6 +168,10 @@ abstract class Expression<D extends Object> implements FunctionParameter {
///
/// For an "is not in" comparison with values, use [isNotIn].
Expression<bool> isNotInExp(List<Expression<D>> expressions) {
if (expressions.isEmpty) {
return Constant(true);
}
return _InExpression(this, expressions, true);
}
@ -472,10 +480,35 @@ class _CastInSqlExpression<D1 extends Object, D2 extends Object>
@override
void writeInto(GenerationContext context) {
final type = DriftSqlType.forType<D2>();
if (type == DriftSqlType.any) {
inner.writeInto(context); // No need to cast
}
final String typeName;
if (context.dialect == SqlDialect.mariadb) {
// MariaDB has a weird cast syntax that uses different type names than the
// ones used in a create table statement.
// ignore: unnecessary_cast
typeName = switch (type as DriftSqlType<Object>) {
DriftSqlType.int ||
DriftSqlType.bigInt ||
DriftSqlType.bool =>
'INTEGER',
DriftSqlType.string => 'CHAR',
DriftSqlType.double => 'DOUBLE',
DriftSqlType.blob => 'BINARY',
DriftSqlType.dateTime => 'DATETIME',
DriftSqlType.any => '',
};
} else {
typeName = type.sqlTypeName(context);
}
context.buffer.write('CAST(');
inner.writeInto(context);
context.buffer.write(' AS ${type.sqlTypeName(context)})');
context.buffer.write(' AS $typeName)');
}
}

View File

@ -58,6 +58,15 @@ class GenerationContext {
/// Gets the generated sql statement
String get sql => buffer.toString();
/// The variable indices occupied by this generation context.
///
/// SQL variables are 1-indexed, so a context with three variables would
/// cover the variables `1`, `2` and `3` by default.
Iterable<int> get variableIndices {
final start = explicitVariableIndex ?? 1;
return Iterable.generate(amountOfVariables, (i) => start + i);
}
/// Constructs a [GenerationContext] by copying the relevant fields from the
/// database.
GenerationContext.fromDb(DatabaseConnectionUser this.executor,
@ -87,6 +96,6 @@ class GenerationContext {
void writeWhitespace() => buffer.write(' ');
/// Turns [columnName] into a safe SQL identifier by wrapping it in double
/// quotes.
String identifier(String columnName) => '"$columnName"';
/// quotes, or backticks depending on the dialect.
String identifier(String columnName) => dialect.escape(columnName);
}

View File

@ -15,6 +15,7 @@ typedef OnUpgrade = Future<void> Function(Migrator m, int from, int to);
typedef OnBeforeOpen = Future<void> Function(OpeningDetails details);
Future<void> _defaultOnCreate(Migrator m) => m.createAll();
Future<void> _defaultOnUpdate(Migrator m, int from, int to) async =>
throw Exception("You've bumped the schema version for your drift database "
"but didn't provide a strategy for schema updates. Please do that by "
@ -227,7 +228,7 @@ class Migrator {
expressionsForSelect.add(expression);
if (!first) context.buffer.write(', ');
context.buffer.write(column.escapedName);
context.buffer.write(column.escapedNameFor(context.dialect));
first = false;
}
}
@ -308,7 +309,7 @@ class Migrator {
for (var i = 0; i < pkList.length; i++) {
final column = pkList[i];
context.buffer.write(column.escapedName);
context.buffer.write(column.escapedNameFor(context.dialect));
if (i != pkList.length - 1) context.buffer.write(', ');
}
@ -322,7 +323,7 @@ class Migrator {
for (var i = 0; i < uqList.length; i++) {
final column = uqList[i];
context.buffer.write(column.escapedName);
context.buffer.write(column.escapedNameFor(context.dialect));
if (i != uqList.length - 1) context.buffer.write(', ');
}
@ -378,7 +379,9 @@ class Migrator {
await _issueQueryByDialect(stmts);
} else if (view.query != null) {
final context = GenerationContext.fromDb(_db, supportsVariables: false);
final columnNames = view.$columns.map((e) => e.escapedName).join(', ');
final columnNames = view.$columns
.map((e) => e.escapedNameFor(context.dialect))
.join(', ');
context.generatingForView = view.entityName;
context.buffer.write('CREATE VIEW IF NOT EXISTS '
@ -447,14 +450,15 @@ class Migrator {
/// in sqlite version 3.25.0, released on 2018-09-15. When you're using
/// Flutter and depend on `sqlite3_flutter_libs`, you're guaranteed to have
/// that version. Otherwise, please ensure that you only use [renameColumn] if
/// you know you'll run on sqlite 3.20.0 or later.
/// you know you'll run on sqlite 3.20.0 or later. In MariaDB support for that
/// same syntax was added in MariaDB version 10.5.2, released on 2020-03-26.
Future<void> renameColumn(
TableInfo table, String oldName, GeneratedColumn column) async {
final context = _createContext();
context.buffer
..write('ALTER TABLE ${context.identifier(table.aliasedName)} ')
..write('RENAME COLUMN ${context.identifier(oldName)} ')
..write('TO ${column.escapedName};');
..write('TO ${column.escapedNameFor(context.dialect)};');
return _issueCustomQuery(context.sql);
}
@ -467,8 +471,13 @@ class Migrator {
/// databases.
Future<void> renameTable(TableInfo table, String oldName) async {
final context = _createContext();
context.buffer.write('ALTER TABLE ${context.identifier(oldName)} '
'RENAME TO ${context.identifier(table.actualTableName)};');
context.buffer.write(switch (context.dialect) {
SqlDialect.mariadb => 'RENAME TABLE ${context.identifier(oldName)} '
'TO ${context.identifier(table.actualTableName)};',
_ => 'ALTER TABLE ${context.identifier(oldName)} '
'RENAME TO ${context.identifier(table.actualTableName)};',
});
return _issueCustomQuery(context.sql);
}

View File

@ -96,11 +96,107 @@ void _writeCommaSeparated(
enum SqlDialect {
/// Use sqlite's sql dialect. This is the default option and the only
/// officially supported dialect at the moment.
sqlite,
sqlite(
booleanType: 'INTEGER',
textType: 'TEXT',
integerType: 'INTEGER',
realType: 'REAL',
blobType: 'BLOB',
),
/// (currently unsupported)
mysql,
@Deprecated('Use mariadb instead, even when talking to a MySQL database')
mysql(
booleanType: '',
textType: '',
integerType: '',
blobType: '',
realType: '',
),
/// PostgreSQL (currently supported in an experimental state)
postgres,
postgres(
booleanType: 'boolean',
textType: 'text',
integerType: 'bigint',
blobType: 'bytea',
realType: 'float8',
),
/// MariaDB (currently supported in an experimental state)
mariadb(
booleanType: 'BOOLEAN',
textType: 'TEXT',
integerType: 'BIGINT',
blobType: 'BLOB',
realType: 'DOUBLE',
escapeChar: '`',
supportsIndexedParameters: false,
);
/// The type to use in `CAST`s and column definitions to store booleans.
final String booleanType;
/// The type to use in `CAST`s and column definitions to store strings.
final String textType;
/// The type to use in `CAST`s and column definitions to store 64-bit
/// integers.
final String integerType;
/// The type to use in `CAST`s and column definitions to store doubles.
final String realType;
/// The type to use in `CAST`s and column definitions to store blobs (as
/// a [Uint8List] in Dart).
final String blobType;
/// The character used to wrap identifiers to distinguish them from keywords.
///
/// This is a double quote character in ANSI SQL, but MariaDB uses backticks
/// by default.
final String escapeChar;
/// Whether this dialect supports indexed parameters.
///
/// For dialects that support this features, an explicit index can be given
/// for parameters, even if it doesn't match the order of occurrences in the
/// given statement (e.g. `INSERT INTO foo VALUES (?1, ?2, ?3, ?4)`).
/// In dialects without this feature, every syntactic occurrence of a variable
/// introduces a new logical variable with a new index, variables also can't
/// be re-used.
final bool supportsIndexedParameters;
/// Escapes [identifier] by wrapping it in [escapeChar].
String escape(String identifier) => '$escapeChar$identifier$escapeChar';
const SqlDialect({
required this.booleanType,
required this.textType,
required this.integerType,
required this.realType,
required this.blobType,
this.escapeChar = '"',
this.supportsIndexedParameters = true,
});
/// For dialects that don't support named or explicitly-indexed variables,
/// translates a variable assignment to avoid using that feature.
///
/// For instance, the SQL snippet `WHERE x = :a OR y = :a` would be translated
/// to `WHERE x = ? OR y = ?`. Then, [original] would contain the value for
/// the single variable and [syntacticOccurences] would contain two values
/// (`1` and `1`) referencing the original variable.
List<Variable> desugarDuplicateVariables(
List<Variable> original,
List<int> syntacticOccurences,
) {
if (supportsIndexedParameters) return original;
return [
for (final occurence in syntacticOccurences)
// Variables in SQL are 1-indexed
original[occurence - 1],
];
}
}

View File

@ -115,6 +115,7 @@ class GeneratedColumn<T extends Object> extends Column<T> {
/// buffer.
void writeColumnDefinition(GenerationContext into) {
final isSerial = into.dialect == SqlDialect.postgres && hasAutoIncrement;
final escapedName = escapedNameFor(into.dialect);
if (isSerial) {
into.buffer.write('$escapedName bigserial PRIMARY KEY NOT NULL');
@ -177,7 +178,8 @@ class GeneratedColumn<T extends Object> extends Column<T> {
..write(context.identifier(tableName))
..write('.');
}
context.buffer.write(ignoreEscape ? $name : escapedName);
context.buffer
.write(ignoreEscape ? $name : escapedNameFor(context.dialect));
}
}

View File

@ -177,10 +177,13 @@ extension NameWithAlias on ResultSetImplementation<dynamic, dynamic> {
/// can be used in select statements, as it returns something like "users u"
/// for a table called users that has been aliased as "u".
String get tableWithAlias {
var dialect = attachedDatabase.executor.dialect;
var entityNameEscaped = dialect.escape(entityName);
var aliasedNameEscaped = dialect.escape(aliasedName);
if (aliasedName == entityName) {
return '"$entityName"';
return entityNameEscaped;
} else {
return '"$entityName" "$aliasedName"';
return '$entityNameEscaped $aliasedNameEscaped';
}
}
}

View File

@ -273,27 +273,31 @@ class InsertStatement<T extends Table, D> {
) {
void writeOnConflictConstraint(
List<Column<Object>>? target, Expression<bool>? where) {
ctx.buffer.write(' ON CONFLICT(');
if (ctx.dialect == SqlDialect.mariadb) {
ctx.buffer.write(' ON DUPLICATE');
} else {
ctx.buffer.write(' ON CONFLICT(');
final conflictTarget = target ?? table.$primaryKey.toList();
final conflictTarget = target ?? table.$primaryKey.toList();
if (conflictTarget.isEmpty) {
throw ArgumentError(
'Table has no primary key, so a conflict target is needed.');
if (conflictTarget.isEmpty) {
throw ArgumentError(
'Table has no primary key, so a conflict target is needed.');
}
var first = true;
for (final target in conflictTarget) {
if (!first) ctx.buffer.write(', ');
// Writing the escaped name directly because it should not have a table
// name in front of it.
ctx.buffer.write(target.escapedNameFor(ctx.dialect));
first = false;
}
ctx.buffer.write(')');
}
var first = true;
for (final target in conflictTarget) {
if (!first) ctx.buffer.write(', ');
// Writing the escaped name directly because it should not have a table
// name in front of it.
ctx.buffer.write(target.escapedName);
first = false;
}
ctx.buffer.write(')');
if (where != null) {
Where(where).writeInto(ctx);
}
@ -325,7 +329,11 @@ class InsertStatement<T extends Table, D> {
mode == InsertMode.insertOrIgnore) {
ctx.buffer.write(' DO NOTHING ');
} else {
ctx.buffer.write(' DO UPDATE SET ');
if (ctx.dialect == SqlDialect.mariadb) {
ctx.buffer.write(' KEY UPDATE ');
} else {
ctx.buffer.write(' DO UPDATE SET ');
}
var first = true;
for (final update in updateSet.entries) {

View File

@ -128,11 +128,12 @@ class JoinedSelectStatement<FirstT extends HasResultSet, FirstD>
chosenAlias = _columnAliases[column]!;
}
final chosenAliasEscaped = ctx.dialect.escape(chosenAlias);
column.writeInto(ctx);
ctx.buffer
..write(' AS "')
..write(chosenAlias)
..write('"');
..write(' AS ')
..write(chosenAliasEscaped);
}
ctx.buffer.write(' FROM ');

View File

@ -321,22 +321,22 @@ enum DriftSqlType<T extends Object> implements _InternalDriftSqlType<T> {
// ignore: unnecessary_cast
switch (this as DriftSqlType<Object>) {
case DriftSqlType.bool:
return dialect == SqlDialect.sqlite ? 'INTEGER' : 'boolean';
return dialect.booleanType;
case DriftSqlType.string:
return dialect == SqlDialect.sqlite ? 'TEXT' : 'text';
return dialect.textType;
case DriftSqlType.bigInt:
case DriftSqlType.int:
return dialect == SqlDialect.sqlite ? 'INTEGER' : 'bigint';
return dialect.integerType;
case DriftSqlType.dateTime:
if (context.typeMapping.storeDateTimesAsText) {
return dialect == SqlDialect.sqlite ? 'TEXT' : 'text';
return dialect.textType;
} else {
return dialect == SqlDialect.sqlite ? 'INTEGER' : 'bigint';
return dialect.integerType;
}
case DriftSqlType.blob:
return dialect == SqlDialect.sqlite ? 'BLOB' : 'bytea';
return dialect.blobType;
case DriftSqlType.double:
return dialect == SqlDialect.sqlite ? 'REAL' : 'float8';
return dialect.realType;
case DriftSqlType.any:
return 'ANY';
}

View File

@ -67,5 +67,14 @@ void main() {
generates(
'name NOT IN (SELECT "users"."name" AS "users.name" FROM "users")'));
});
test('avoids generating empty tuples', () {
// Some dialects don't support the `x IS IN ()` form, so we should avoid
// it and replace it with the direct constant (since nothing can be a
// member of the empty set). sqlite3 seems to do the same thing, as
// `NULL IN ()` is `0` and not `NULL`.
expect(innerExpression.isIn([]), generates('0'));
expect(innerExpression.isNotIn([]), generates('1'));
});
});
}

View File

@ -22,6 +22,7 @@ import 'required_variables.dart';
/// class is simply there to bundle the data.
class _QueryHandlerContext {
final List<FoundElement> foundElements;
final List<SyntacticElementReference> elementReferences;
final AstNode root;
final NestedQueriesContainer? nestedScope;
final String queryName;
@ -32,13 +33,15 @@ class _QueryHandlerContext {
_QueryHandlerContext({
required List<FoundElement> foundElements,
required List<SyntacticElementReference> elementReferences,
required this.root,
required this.queryName,
required this.nestedScope,
this.requestedResultClass,
this.requestedResultType,
this.sourceForFixedName,
}) : foundElements = List.unmodifiable(foundElements);
}) : foundElements = List.unmodifiable(foundElements),
elementReferences = List.unmodifiable(elementReferences);
}
/// Maps an [AnalysisContext] from the sqlparser to a [SqlQuery] from this
@ -103,7 +106,7 @@ class QueryAnalyzer {
nestedScope = nestedAnalyzer.analyzeRoot(context.root as SelectStatement);
}
final foundElements = _extractElements(
final (foundElements, references) = _extractElements(
ctx: context,
root: context.root,
required: requiredVariables,
@ -120,6 +123,7 @@ class QueryAnalyzer {
final query = _mapToDrift(_QueryHandlerContext(
foundElements: foundElements,
elementReferences: references,
queryName: declaration.name,
requestedResultClass: requestedResultClass,
requestedResultType: requestedResultType,
@ -209,6 +213,7 @@ class QueryAnalyzer {
context,
root,
queryContext.foundElements,
queryContext.elementReferences,
updatedFinder.writtenTables
.map((write) {
final table = _lookupReference<DriftTable?>(write.table.name);
@ -265,6 +270,7 @@ class QueryAnalyzer {
context,
queryContext.root,
queryContext.foundElements,
queryContext.elementReferences,
driftEntities,
_inferResultSet(queryContext, resolvedColumns, syntacticColumns),
queryContext.requestedResultClass,
@ -447,6 +453,7 @@ class QueryAnalyzer {
final driftResultSet = _inferResultSet(
_QueryHandlerContext(
foundElements: queryContext.foundElements,
elementReferences: queryContext.elementReferences,
root: queryContext.root,
queryName: queryContext.queryName,
nestedScope: queryContext.nestedScope,
@ -484,7 +491,7 @@ class QueryAnalyzer {
_QueryHandlerContext queryContext, NestedQueryColumn column) {
final childScope = queryContext.nestedScope?.nestedQueries[column];
final foundElements = _extractElements(
final (foundElements, references) = _extractElements(
ctx: context,
root: column.select,
required: requiredVariables,
@ -511,6 +518,7 @@ class QueryAnalyzer {
requestedResultClass: resultClassName,
root: column.select,
foundElements: foundElements,
elementReferences: references,
nestedScope: childScope,
)),
);
@ -564,7 +572,7 @@ class QueryAnalyzer {
/// a Dart placeholder, its indexed is LOWER than that element. This means
/// that elements can be expanded into multiple variables without breaking
/// variables that appear after them.
List<FoundElement> _extractElements({
(List<FoundElement>, List<SyntacticElementReference>) _extractElements({
required AnalysisContext ctx,
required AstNode root,
NestedQueriesContainer? nestedScope,
@ -581,6 +589,8 @@ class QueryAnalyzer {
final merged = _mergeVarsAndPlaceholders(variables, placeholders);
final foundElements = <FoundElement>[];
final references = <SyntacticElementReference>[];
// we don't allow variables with an explicit index after an array. For
// instance: SELECT * FROM t WHERE id IN ? OR id = ?2. The reason this is
// not allowed is that we expand the first arg into multiple vars at runtime
@ -589,9 +599,15 @@ class QueryAnalyzer {
var maxIndex = 999;
var currentIndex = 0;
void addNewElement(FoundElement element) {
foundElements.add(element);
references.add(SyntacticElementReference(element));
}
for (final used in merged) {
if (used is Variable) {
if (used.resolvedIndex == currentIndex) {
references.add(SyntacticElementReference(foundElements.last));
continue; // already handled, we only report a single variable / index
}
@ -624,7 +640,7 @@ class QueryAnalyzer {
final type = driver.typeMapping.sqlTypeToDrift(internalType.type);
if (forCapture != null) {
foundElements.add(FoundVariable.nestedQuery(
addNewElement(FoundVariable.nestedQuery(
index: currentIndex,
name: name,
sqlType: type,
@ -657,7 +673,7 @@ class QueryAnalyzer {
converter = (internalType.type!.hint as TypeConverterHint).converter;
}
foundElements.add(FoundVariable(
addNewElement(FoundVariable(
index: currentIndex,
name: name,
sqlType: type,
@ -684,10 +700,10 @@ class QueryAnalyzer {
// we don't what index this placeholder has, so we can't allow _any_
// explicitly indexed variables coming after this
maxIndex = 0;
foundElements.add(_extractPlaceholder(ctx, used));
addNewElement(_extractPlaceholder(ctx, used));
}
}
return foundElements;
return (foundElements, references);
}
FoundDartPlaceholder _extractPlaceholder(

View File

@ -103,6 +103,13 @@ enum QueryMode {
atCreate,
}
///A reference to a [FoundElement] occuring in the SQL query.
class SyntacticElementReference {
final FoundElement referencedElement;
SyntacticElementReference(this.referencedElement);
}
/// A fully-resolved and analyzed SQL query.
abstract class SqlQuery {
final String name;
@ -143,22 +150,44 @@ abstract class SqlQuery {
/// if their index is lower than that of the array (e.g `a = ?2 AND b IN ?
/// AND c IN ?1`. In other words, we can expand an array without worrying
/// about the variables that appear after that array.
late List<FoundVariable> variables;
late final List<FoundVariable> variables =
elements.whereType<FoundVariable>().toList();
/// The placeholders in this query which are bound and converted to sql at
/// runtime. For instance, in `SELECT * FROM tbl WHERE $expr`, the `expr` is
/// going to be a [FoundDartPlaceholder] with the type
/// [ExpressionDartPlaceholderType] and [DriftSqlType.bool]. We will
/// generate a method which has a `Expression<bool, BoolType> expr` parameter.
late List<FoundDartPlaceholder> placeholders;
late final List<FoundDartPlaceholder> placeholders =
elements.whereType<FoundDartPlaceholder>().toList();
/// Union of [variables] and [placeholders], but in the order in which they
/// appear inside the query.
final List<FoundElement> elements;
SqlQuery(this.name, this.elements) {
variables = elements.whereType<FoundVariable>().toList();
placeholders = elements.whereType<FoundDartPlaceholder>().toList();
/// All references to any [FoundElement] in [elements], but in the order in
/// which they appear in the query.
///
/// This is very similar to [elements] itself, except that elements referenced
/// multiple times are also in this list multiple times. For instance, the
/// query `SELECT * FROM foo WHERE ?1 ORDER BY $order LIMIT ?1` would have two
/// elements (the variable and the Dart template, in that order), but three
/// references (the variable, the template, and then the variable again).
final List<SyntacticElementReference> elementSources;
SqlQuery(this.name, this.elements, this.elementSources);
/// Whether any element in [elements] has more than one definite
/// [elementSources] pointing to it.
bool get referencesAnyElementMoreThanOnce {
final found = <FoundElement>{};
for (final source in elementSources) {
if (!found.add(source.referencedElement)) {
return true;
}
}
return false;
}
bool get _useResultClassName {
@ -239,11 +268,12 @@ class SqlSelectQuery extends SqlQuery {
this.fromContext,
this.root,
List<FoundElement> elements,
List<SyntacticElementReference> elementSources,
this.readsFrom,
this.resultSet,
this.requestedResultClass,
this.nestedContainer,
) : super(name, elements);
) : super(name, elements, elementSources);
Set<DriftTable> get readsFromTables {
return {
@ -264,6 +294,7 @@ class SqlSelectQuery extends SqlQuery {
fromContext,
root,
elements,
elementSources,
readsFrom,
resultSet,
null,
@ -372,10 +403,11 @@ class UpdatingQuery extends SqlQuery {
this.fromContext,
this.root,
List<FoundElement> elements,
List<SyntacticElementReference> elementSources,
this.updates, {
this.isInsert = false,
this.resultSet,
}) : super(name, elements);
}) : super(name, elements, elementSources);
}
/// A special kind of query running multiple inner queries in a transaction.
@ -383,7 +415,11 @@ class InTransactionQuery extends SqlQuery {
final List<SqlQuery> innerQueries;
InTransactionQuery(this.innerQueries, String name)
: super(name, [for (final query in innerQueries) ...query.elements]);
: super(
name,
[for (final query in innerQueries) ...query.elements],
[for (final query in innerQueries) ...query.elementSources],
);
@override
InferredResultSet? get resultSet => null;
@ -831,7 +867,7 @@ final class NestedResultQuery extends NestedResult {
/// Something in the query that needs special attention when generating code,
/// such as variables or Dart placeholders.
abstract class FoundElement {
sealed class FoundElement {
String get dartParameterName;
/// The name of this element as declared in the query
@ -858,8 +894,27 @@ class FoundVariable extends FoundElement implements HasType {
/// three [Variable]s in its AST, but only two [FoundVariable]s, where the
/// `?` will have index 1 and (both) `:xyz` variables will have index 2. We
/// only report one [FoundVariable] per index.
///
/// This [index] might change in the generator as variables are moved around.
/// See [originalIndex] for the original index and a further discussion of
/// this.
int index;
/// The original index this variable had in the SQL string written by the
/// user.
///
/// In the generator, we might have to shuffle variable indices around a bit
/// to support array variables which occupy a dynamic amount of variable
/// indices at runtime.
/// For instance, consider `SELECT * FROM foo WHERE a = :a OR b IN :b OR c = :c`.
/// Here, `:c` will have an original index of 3. Since `:b` is an array
/// variable though, the actual query sent to the database system at runtime
/// will look like `SELECT * FROM foo WHERE a = ?1 OR b IN (?3, ?4) OR c = ?2`
/// when a size-2 list is passed for `b`. All non-array variables have been
/// given indices that appear before the array to support this, so the [index]
/// of `c` would then be `2`.
final int originalIndex;
/// The name of this variable, or null if it's not a named variable.
@override
String? name;
@ -875,10 +930,8 @@ class FoundVariable extends FoundElement implements HasType {
@override
final bool nullable;
/// The first [Variable] in the sql statement that has this [index].
// todo: Do we really need to expose this? We only use [resolvedIndex], which
// should always be equal to [index].
final Variable variable;
@override
final AstNode syntacticOrigin;
/// Whether this variable is an array, which will be expanded into multiple
/// variables at runtime. We only accept queries where no explicitly numbered
@ -900,38 +953,38 @@ class FoundVariable extends FoundElement implements HasType {
required this.index,
required this.name,
required this.sqlType,
required this.variable,
required Variable variable,
this.nullable = false,
this.isArray = false,
this.isRequired = false,
this.typeConverter,
}) : hidden = false,
forCaptured = null,
assert(variable.resolvedIndex == index);
}) : originalIndex = index,
hidden = false,
syntacticOrigin = variable,
forCaptured = null;
FoundVariable.nestedQuery({
required this.index,
required this.name,
required this.sqlType,
required this.variable,
required Variable variable,
required this.forCaptured,
}) : typeConverter = null,
}) : originalIndex = index,
typeConverter = null,
nullable = false,
isArray = false,
isRequired = true,
hidden = true;
hidden = true,
syntacticOrigin = variable;
@override
String get dartParameterName {
if (name != null) {
return dartNameForSqlColumn(name!);
} else {
return 'var${variable.resolvedIndex}';
return 'var$index';
}
}
@override
AstNode get syntacticOrigin => variable;
}
abstract class DartPlaceholderType {}

View File

@ -213,6 +213,7 @@ const _$SqlDialectEnumMap = {
SqlDialect.sqlite: 'sqlite',
SqlDialect.mysql: 'mysql',
SqlDialect.postgres: 'postgres',
SqlDialect.mariadb: 'mariadb',
};
SqliteAnalysisOptions _$SqliteAnalysisOptionsFromJson(Map json) =>

View File

@ -755,9 +755,40 @@ class _ExpandedVariableWriter {
_ExpandedVariableWriter(this.query, this._emitter);
void writeVariables() {
_buffer.write('variables: [');
_writeNewVariables();
_buffer.write(']');
_buffer.write('variables: ');
// Some dialects don't support variables with an explicit index. In that
// case, we have to desugar them by duplicating variables, e.g. `:a AND :a`
// would be transformed to `? AND ?` with us binding the value to both
// variables.
if (_emitter.writer.options.supportedDialects
.any((e) => !e.supportsIndexedParameters) &&
query.referencesAnyElementMoreThanOnce) {
_buffer.write('executor.dialect.desugarDuplicateVariables([');
_writeNewVariables();
_buffer.write('],');
// Every time a variable is used in the generated SQL text, we have to
// track the variable's index in the second list
_buffer.write('[');
for (final source in query.elementSources) {
switch (source.referencedElement) {
case FoundVariable variable:
_buffer.write('${variable.index}, ');
break;
case FoundDartPlaceholder placeholder:
final context = placeholderContextName(placeholder);
_buffer.write('...$context.variableIndices');
break;
}
}
_buffer.write('])');
} else {
_buffer.write('[');
_writeNewVariables();
_buffer.write(']');
}
}
void _writeNewVariables() {

View File

@ -1,6 +1,8 @@
import 'package:charcode/ascii.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show SqlDialect;
// ignore: deprecated_member_use
import 'package:drift/sqlite_keywords.dart';
import 'package:sqlparser/sqlparser.dart';
import 'package:sqlparser/utils/node_to_text.dart';
@ -87,25 +89,34 @@ class SqlWriter extends NodeSqlBuilder {
@override
bool isKeyword(String lexeme) {
switch (dialect) {
case SqlDialect.postgres:
return isKeywordLexeme(lexeme) || isPostgresKeywordLexeme(lexeme);
default:
return isKeywordLexeme(lexeme);
}
return isKeywordLexeme(lexeme) ||
switch (dialect) {
SqlDialect.postgres => isPostgresKeywordLexeme(lexeme),
SqlDialect.mariadb =>
additionalMariaDBKeywords.contains(lexeme.toUpperCase()),
_ => false,
};
}
FoundVariable? _findMoorVar(Variable target) {
return query!.variables.firstWhereOrNull(
(f) => f.variable.resolvedIndex == target.resolvedIndex);
@override
String escapeIdentifier(String identifier) {
return dialect.escape(identifier);
}
void _writeMoorVariable(FoundVariable variable) {
FoundVariable? _findVariable(Variable target) {
return query!.variables
.firstWhereOrNull((f) => f.originalIndex == target.resolvedIndex);
}
void _writeAnalyzedVariable(FoundVariable variable) {
if (variable.isArray) {
_writeRawInSpaces('(\$${expandedName(variable)})');
} else {
final mark = _isPostgres ? '\\\$' : '?';
_writeRawInSpaces('$mark${variable.index}');
final syntax =
dialect.supportsIndexedParameters ? '$mark${variable.index}' : mark;
_writeRawInSpaces(syntax);
}
}
@ -164,9 +175,9 @@ class SqlWriter extends NodeSqlBuilder {
@override
void visitNamedVariable(ColonNamedVariable e, void arg) {
final moor = _findMoorVar(e);
if (moor != null) {
_writeMoorVariable(moor);
final found = _findVariable(e);
if (found != null) {
_writeAnalyzedVariable(found);
} else {
super.visitNamedVariable(e, arg);
}
@ -174,9 +185,9 @@ class SqlWriter extends NodeSqlBuilder {
@override
void visitNumberedVariable(NumberedVariable e, void arg) {
final moor = _findMoorVar(e);
if (moor != null) {
_writeMoorVariable(moor);
final found = _findVariable(e);
if (found != null) {
_writeAnalyzedVariable(found);
} else {
super.visitNumberedVariable(e, arg);
}
@ -205,7 +216,12 @@ class SqlWriter extends NodeSqlBuilder {
}
final columnName = column.name;
_out.write('"$table"."$columnName" AS "$prefix.$columnName"');
final escapedTable = escapeIdentifier(table);
final escapedColumn = escapeIdentifier(columnName);
final escapedAlias = escapeIdentifier('$prefix.$columnName');
_out.write('$escapedTable.$escapedColumn AS $escapedAlias');
}
} else if (e is DartPlaceholder) {
final moorPlaceholder =

View File

@ -16,9 +16,19 @@ Map<SqlDialect, String> defaultConstraints(DriftColumn column) {
for (final feature in column.constraints) {
if (feature is PrimaryKeyColumn) {
if (!wrotePkConstraint) {
defaultConstraints.add(feature.isAutoIncrement
? 'PRIMARY KEY AUTOINCREMENT'
: 'PRIMARY KEY');
if (feature.isAutoIncrement) {
for (final dialect in SqlDialect.values) {
if (dialect == SqlDialect.mariadb) {
dialectSpecificConstraints[dialect]!
.add('PRIMARY KEY AUTO_INCREMENT');
} else {
dialectSpecificConstraints[dialect]!
.add('PRIMARY KEY AUTOINCREMENT');
}
}
} else {
defaultConstraints.add('PRIMARY KEY');
}
wrotePkConstraint = true;
break;
@ -63,9 +73,11 @@ Map<SqlDialect, String> defaultConstraints(DriftColumn column) {
}
if (column.sqlType == DriftSqlType.bool) {
final name = '"${column.nameInSql}"';
final name = column.nameInSql;
dialectSpecificConstraints[SqlDialect.sqlite]!
.add('CHECK ($name IN (0, 1))');
.add('CHECK (${SqlDialect.sqlite.escape(name)} IN (0, 1))');
dialectSpecificConstraints[SqlDialect.mariadb]!
.add('CHECK (${SqlDialect.mariadb.escape(name)} IN (0, 1))');
}
for (final constraints in dialectSpecificConstraints.values) {

View File

@ -169,9 +169,8 @@ void main() {
expect(generated, contains('.toList()'));
});
group('generates correct code for expanded arrays', () {
Future<void> runTest(DriftOptions options, Matcher expectation) async {
final result = await generateForQueryInDriftFile('''
test('generates correct code for expanded arrays', () async {
final result = await generateForQueryInDriftFile('''
CREATE TABLE tbl (
a TEXT,
b TEXT,
@ -179,22 +178,17 @@ CREATE TABLE tbl (
);
query: SELECT * FROM tbl WHERE a = :a AND b IN :b AND c = :c;
''', options: options);
expect(result, expectation);
}
test('with the new query generator', () {
return runTest(
const DriftOptions.defaults(),
allOf(
contains(r'var $arrayStartIndex = 3;'),
contains(r'SELECT * FROM tbl WHERE a = ?1 AND b IN ($expandedb) '
'AND c = ?2'),
contains(r'variables: [Variable<String>(a), Variable<String>(c), '
r'for (var $ in b) Variable<String>($)], readsFrom: {tbl'),
),
);
});
''');
expect(
result,
allOf(
contains(r'var $arrayStartIndex = 3;'),
contains(r'SELECT * FROM tbl WHERE a = ?1 AND b IN ($expandedb) '
'AND c = ?2'),
contains(r'variables: [Variable<String>(a), Variable<String>(c), '
r'for (var $ in b) Variable<String>($)], readsFrom: {tbl'),
),
);
});
test(

View File

@ -14,7 +14,7 @@ void main() {
}) {
final engine = SqlEngine();
final context = engine.analyze(sql);
final query = SqlSelectQuery('name', context, context.root, [], [],
final query = SqlSelectQuery('name', context, context.root, [], [], [],
InferredResultSet(null, []), null, null);
final result = SqlWriter(options, dialect: dialect, query: query).write();

View File

@ -831,8 +831,10 @@ class GroupCount extends ViewInfo<GroupCount, GroupCountData>
@override
String get entityName => 'group_count';
@override
String get createViewStmt =>
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users';
Map<SqlDialect, String> get createViewStatements => {
SqlDialect.sqlite:
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users',
};
@override
GroupCount get asDslTable => this;
@override

View File

@ -1,6 +1,6 @@
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY.
final class _S2 extends i0.VersionedSchema {

View File

@ -490,8 +490,10 @@ class GroupCount extends ViewInfo<GroupCount, GroupCountData>
@override
String get entityName => 'group_count';
@override
String get createViewStmt =>
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users';
Map<SqlDialect, String> get createViewStatements => {
SqlDialect.sqlite:
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users'
};
@override
GroupCount get asDslTable => this;
@override

View File

@ -531,8 +531,10 @@ class GroupCount extends ViewInfo<GroupCount, GroupCountData>
@override
String get entityName => 'group_count';
@override
String get createViewStmt =>
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users';
Map<SqlDialect, String> get createViewStatements => {
SqlDialect.sqlite:
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users'
};
@override
GroupCount get asDslTable => this;
@override

View File

@ -531,8 +531,10 @@ class GroupCount extends ViewInfo<GroupCount, GroupCountData>
@override
String get entityName => 'group_count';
@override
String get createViewStmt =>
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users';
Map<SqlDialect, String> get createViewStatements => {
SqlDialect.sqlite:
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users'
};
@override
GroupCount get asDslTable => this;
@override

View File

@ -535,8 +535,10 @@ class GroupCount extends ViewInfo<GroupCount, GroupCountData>
@override
String get entityName => 'group_count';
@override
String get createViewStmt =>
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users';
Map<SqlDialect, String> get createViewStatements => {
SqlDialect.sqlite:
'CREATE VIEW group_count AS SELECT users.*, (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count FROM users'
};
@override
GroupCount get asDslTable => this;
@override

View File

@ -735,8 +735,10 @@ class GroupCount extends ViewInfo<GroupCount, GroupCountData>
@override
String get entityName => 'group_count';
@override
String get createViewStmt =>
'CREATE VIEW group_count AS SELECT\n users.*,\n (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count\n FROM users;';
Map<SqlDialect, String> get createViewStatements => {
SqlDialect.sqlite:
'CREATE VIEW group_count AS SELECT\n users.*,\n (SELECT COUNT(*) FROM "groups" WHERE owner = users.id) AS group_count\n FROM users;'
};
@override
GroupCount get asDslTable => this;
@override

11
extras/drift_mariadb/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Files and directories created by pub
.dart_tool/
.packages
# Remove the following pattern if you wish to check in your lock file
pubspec.lock
# Conventional directory for build outputs
build/
# Directory created by dartdoc
doc/api/

View File

@ -0,0 +1,29 @@
An experimental mariadb backend for Drift.
Note that the backend is currently experimental and not fully functional yet.
## Using this
For general notes on using drift, see [this guide](https://drift.simonbinder.eu/getting-started/).
To use drift_mariadb, add this to your `pubspec.yaml`
```yaml
dependencies:
drift: "$latest version"
drift_mariadb:
git:
url: https://github.com/simolus3/drift.git
path: extras/drift_mariadb
```
To connect your drift database class to mariadb, use a `MariaDBDatabase` from `package:drift_mariadb/mariadb.dart`.
## Testing
To test this package, first run
```
docker run -p 3306:3306 -e MARIADB_ROOT_PASSWORD=password -e MARIADB_DATABASE=database mariadb:latest
```
It can then be tested with `dart test`.

View File

@ -0,0 +1,5 @@
include: package:lints/recommended.yaml
analyzer:
language:
strict-casts: true

View File

@ -0,0 +1,37 @@
import 'package:drift/backends.dart';
import 'package:drift/src/runtime/query_builder/query_builder.dart';
import 'package:drift_mariadb/drift_mariadb.dart';
import 'package:mysql_client/mysql_client.dart';
void main() async {
final mariadb = MariaDBDatabase(
pool: MySQLConnectionPool(
host: 'localhost',
port: 3306,
userName: 'root',
password: 'password',
databaseName: 'database',
maxConnections: 1,
secure: false,
),
logStatements: true,
);
await mariadb.ensureOpen(_NullUser());
final rows = await mariadb.runSelect(r'SELECT (?)', [true]);
final row = rows.single;
print(row);
print(row.values.map((e) => e.runtimeType).toList());
}
class _NullUser extends QueryExecutorUser {
@override
Future<void> beforeOpen(
QueryExecutor executor,
OpeningDetails details,
) async {}
@override
int get schemaVersion => 1;
}

View File

@ -0,0 +1,7 @@
/// Experimental Drift integration for MariaDB.
@experimental
library drift.mariadb;
import 'package:meta/meta.dart';
export 'src/mariadb_database.dart';

View File

@ -0,0 +1,225 @@
import 'dart:async';
import 'package:drift/backends.dart';
import 'package:mysql_client/mysql_client.dart';
/// A drift database implementation that talks to a mariadb database.
class MariaDBDatabase extends DelegatedDatabase {
MariaDBDatabase({
required MySQLConnectionPool pool,
bool isSequential = true,
bool logStatements = false,
}) : super(
_MariaDelegate(() => pool, true),
isSequential: isSequential,
logStatements: logStatements,
);
/// Creates a drift database implementation from a mariadb database
/// [connection].
MariaDBDatabase.opened(
MySQLConnectionPool connection, {
bool logStatements = false,
}) : super(
_MariaDelegate(() => connection, false),
isSequential: true,
logStatements: logStatements,
);
@override
SqlDialect get dialect => SqlDialect.mariadb;
}
class _MariaDelegate extends DatabaseDelegate {
_MariaDelegate(this._open, this.closeUnderlyingWhenClosed);
final bool closeUnderlyingWhenClosed;
final FutureOr<MySQLConnectionPool> Function() _open;
MySQLConnectionPool? _openedSession;
@override
TransactionDelegate get transactionDelegate => NoTransactionDelegate(
start: 'START TRANSACTION',
commit: 'COMMIT',
rollback: 'ROLLBACK',
savepoint: (int depth) => 'SAVEPOINT s$depth',
release: (int depth) => 'RELEASE SAVEPOINT s$depth',
rollbackToSavepoint: (int depth) => 'ROLLBACK TO SAVEPOINT s$depth',
);
@override
late DbVersionDelegate versionDelegate;
@override
Future<bool> get isOpen => Future.value(_openedSession != null);
@override
Future<void> open(QueryExecutorUser user) async {
final session = await _open();
final mariaVersionDelegate = _MariaVersionDelegate(session);
await mariaVersionDelegate.init();
_openedSession = session;
versionDelegate = mariaVersionDelegate;
}
@override
Future<void> runBatched(BatchedStatements statements) async {
final session = _openedSession!;
final prepared = List<PreparedStmt?>.filled(
statements.statements.length,
null,
);
try {
for (final instantiation in statements.arguments) {
final mariaArgs = _BoundArguments.ofDartArgs(instantiation.arguments);
final stmtIndex = instantiation.statementIndex;
var stmt = prepared[stmtIndex];
final sql = statements.statements[stmtIndex];
stmt ??= prepared[stmtIndex] = await session.prepare(sql);
await stmt.execute(mariaArgs.parameters);
}
} finally {
for (var stmt in prepared) {
stmt?.deallocate();
}
}
}
Future<int> _runWithArgs(String statement, List<Object?> args) async {
final session = _openedSession!;
IResultSet result;
if (args.isEmpty) {
result = await session.execute(statement);
} else {
var mariaArgs = _BoundArguments.ofDartArgs(args);
var stmt = await session.prepare(statement);
result = await stmt.execute(mariaArgs.parameters);
}
return result.affectedRows.toInt();
}
@override
Future<void> runCustom(String statement, List<Object?> args) async {
await _runWithArgs(statement, args);
}
@override
Future<int> runInsert(String statement, List<Object?> args) async {
final session = _openedSession!;
IResultSet result;
if (args.isEmpty) {
result = await session.execute(statement);
} else {
var mariaArgs = _BoundArguments.ofDartArgs(args);
var stmt = await session.prepare(statement);
result = await stmt.execute(mariaArgs.parameters);
}
return result.firstOrNull?.lastInsertID.toInt() ?? 0;
}
@override
Future<int> runUpdate(String statement, List<Object?> args) async {
return _runWithArgs(statement, args);
}
@override
Future<QueryResult> runSelect(String statement, List<Object?> args) async {
var session = _openedSession!;
IResultSet result;
if (args.isEmpty) {
result = await session.execute(statement);
} else {
var mariaArgs = _BoundArguments.ofDartArgs(args);
var stmt = await session.prepare(statement);
result = await stmt.execute(mariaArgs.parameters);
}
print(statement);
var rowsList = result.rows.toList();
var rowsParsed = List.generate(
rowsList.length,
(index) => rowsList[index].typedAssoc().values.toList(),
);
return QueryResult(
[for (final mariaColumn in result.cols) mariaColumn.name],
rowsParsed,
);
}
@override
Future<void> close() async {
if (closeUnderlyingWhenClosed) {
await _openedSession?.close();
}
}
}
class _BoundArguments {
final List<Object?> parameters;
_BoundArguments(this.parameters);
factory _BoundArguments.ofDartArgs(List<Object?> args) {
final parameters = <Object?>[];
void add(Object? param) {
parameters.add(param);
}
for (final value in args) {
if (value == null) {
add(null);
} else if (value is int) {
add(value);
} else if (value is BigInt) {
add(value);
} else if (value is bool) {
add(value ? 1 : 0);
} else if (value is double) {
add(value);
} else if (value is String) {
add(value);
} else {
throw ArgumentError.value(value, 'value', 'Unsupported type');
}
}
return _BoundArguments(parameters);
}
}
class _MariaVersionDelegate extends DynamicVersionDelegate {
final MySQLConnectionPool database;
_MariaVersionDelegate(this.database);
@override
Future<int> get schemaVersion async {
final result = await database.execute('SELECT version FROM __schema');
return result.rows.first.typedAssoc()['version'] as int;
}
Future init() async {
await database.execute('CREATE TABLE IF NOT EXISTS __schema (version '
'integer NOT NULL DEFAULT 0)');
final count = await database.execute('SELECT COUNT(*) FROM __schema');
if (count.rows.first.typedAssoc()['COUNT(*)'] as int == 0) {
await database.execute('INSERT INTO __schema (version) VALUES (0)');
}
}
@override
Future<void> setSchemaVersion(int version) async {
var stmt = await database.prepare(r'UPDATE __schema SET version = (?)');
await stmt.execute([version]);
}
}

View File

@ -0,0 +1,17 @@
name: drift_mariadb
description: Mariadb support for drift.
version: 1.0.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
drift: ^2.0.0
meta: ^1.9.1
mysql_client: ^0.0.27
dev_dependencies:
lints: ^2.0.0
test: ^1.21.0
drift_testcases:
path: ../integration_tests/drift_testcases

View File

@ -0,0 +1,41 @@
import 'package:drift_mariadb/drift_mariadb.dart';
import 'package:drift_testcases/tests.dart';
import 'package:mysql_client/mysql_client.dart';
class MariaDbExecutor extends TestExecutor {
@override
bool get supportsReturning => true;
@override
bool get supportsNestedTransactions => true;
@override
DatabaseConnection createConnection() {
final pool = MySQLConnectionPool(
host: 'localhost',
port: 3306,
userName: 'root',
password: 'password',
databaseName: 'database',
maxConnections: 1,
secure: false,
);
return DatabaseConnection(MariaDBDatabase(pool: pool, logStatements: true));
}
@override
Future clearDatabaseAndClose(Database db) async {
await db.customStatement('DROP DATABASE `database`;');
await db.customStatement('CREATE DATABASE `database`;');
await db.close();
}
@override
Future<void> deleteData() async {}
}
void main() {
runAllTests(MariaDbExecutor());
}

View File

@ -3,7 +3,7 @@ description: Postgres support for drift
version: 1.0.0
environment:
sdk: '>=2.12.0-0 <3.0.0'
sdk: '>=2.12.0-0 <4.0.0'
dependencies:
collection: ^1.16.0

View File

@ -9,7 +9,12 @@ targets:
raw_result_set_data: false
named_parameters: false
sql:
dialects: [sqlite, postgres]
# Generate multi-dialect code so that the tests can run on each DBMS we're
# working on (even though only sqlite is supported officially).
dialects:
- sqlite
- postgres
- mariadb
options:
version: "3.37"
modules:

View File

@ -10,13 +10,16 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> {
$UsersTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
late final GeneratedColumn<int> id =
GeneratedColumn<int>('id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'PRIMARY KEY AUTOINCREMENT',
SqlDialect.postgres: 'PRIMARY KEY AUTOINCREMENT',
SqlDialect.mariadb: 'PRIMARY KEY AUTO_INCREMENT',
}));
static const VerificationMeta _nameMeta = const VerificationMeta('name');
@override
late final GeneratedColumn<String> name = GeneratedColumn<String>(
@ -337,6 +340,7 @@ class $FriendshipsTable extends Friendships
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'CHECK ("really_good_friends" IN (0, 1))',
SqlDialect.postgres: '',
SqlDialect.mariadb: 'CHECK (`really_good_friends` IN (0, 1))',
}),
defaultValue: const Constant(false));
@override
@ -556,9 +560,11 @@ abstract class _$Database extends GeneratedDatabase {
switch (executor.dialect) {
SqlDialect.sqlite =>
'SELECT * FROM users AS u ORDER BY (SELECT COUNT(*) FROM friendships WHERE first_user = u.id OR second_user = u.id) DESC LIMIT ?1',
SqlDialect.postgres ||
_ =>
SqlDialect.postgres =>
'SELECT * FROM users AS u ORDER BY (SELECT COUNT(*) FROM friendships WHERE first_user = u.id OR second_user = u.id) DESC LIMIT \$1',
SqlDialect.mariadb ||
_ =>
'SELECT * FROM users AS u ORDER BY (SELECT COUNT(*) FROM friendships WHERE first_user = u.id OR second_user = u.id) DESC LIMIT ?',
},
variables: [
Variable<int>(amount)
@ -574,13 +580,18 @@ abstract class _$Database extends GeneratedDatabase {
switch (executor.dialect) {
SqlDialect.sqlite =>
'SELECT COUNT(*) AS _c0 FROM friendships AS f WHERE f.really_good_friends = TRUE AND(f.first_user = ?1 OR f.second_user = ?1)',
SqlDialect.postgres ||
_ =>
SqlDialect.postgres =>
'SELECT COUNT(*) AS _c0 FROM friendships AS f WHERE f.really_good_friends = TRUE AND(f.first_user = \$1 OR f.second_user = \$1)',
SqlDialect.mariadb ||
_ =>
'SELECT COUNT(*) AS _c0 FROM friendships AS f WHERE f.really_good_friends = TRUE AND(f.first_user = ? OR f.second_user = ?)',
},
variables: [
variables: executor.dialect.desugarDuplicateVariables([
Variable<int>(user)
],
], [
1,
1,
]),
readsFrom: {
friendships,
}).map((QueryRow row) => row.read<int>('_c0'));
@ -591,13 +602,19 @@ abstract class _$Database extends GeneratedDatabase {
switch (executor.dialect) {
SqlDialect.sqlite =>
'SELECT f.really_good_friends,"user"."id" AS "nested_0.id", "user"."name" AS "nested_0.name", "user"."birth_date" AS "nested_0.birth_date", "user"."profile_picture" AS "nested_0.profile_picture", "user"."preferences" AS "nested_0.preferences" FROM friendships AS f INNER JOIN users AS user ON user.id IN (f.first_user, f.second_user) AND user.id != ?1 WHERE(f.first_user = ?1 OR f.second_user = ?1)',
SqlDialect.postgres ||
_ =>
SqlDialect.postgres =>
'SELECT f.really_good_friends,"user"."id" AS "nested_0.id", "user"."name" AS "nested_0.name", "user"."birth_date" AS "nested_0.birth_date", "user"."profile_picture" AS "nested_0.profile_picture", "user"."preferences" AS "nested_0.preferences" FROM friendships AS f INNER JOIN users AS "user" ON "user".id IN (f.first_user, f.second_user) AND "user".id != \$1 WHERE(f.first_user = \$1 OR f.second_user = \$1)',
SqlDialect.mariadb ||
_ =>
'SELECT f.really_good_friends,`user`.`id` AS `nested_0.id`, `user`.`name` AS `nested_0.name`, `user`.`birth_date` AS `nested_0.birth_date`, `user`.`profile_picture` AS `nested_0.profile_picture`, `user`.`preferences` AS `nested_0.preferences` FROM friendships AS f INNER JOIN users AS user ON user.id IN (f.first_user, f.second_user) AND user.id != ? WHERE(f.first_user = ? OR f.second_user = ?)',
},
variables: [
variables: executor.dialect.desugarDuplicateVariables([
Variable<int>(user)
],
], [
1,
1,
1,
]),
readsFrom: {
friendships,
users,
@ -619,9 +636,10 @@ abstract class _$Database extends GeneratedDatabase {
return customSelect(
switch (executor.dialect) {
SqlDialect.sqlite => 'SELECT preferences FROM users WHERE id = ?1',
SqlDialect.postgres ||
SqlDialect.postgres => 'SELECT preferences FROM users WHERE id = \$1',
SqlDialect.mariadb ||
_ =>
'SELECT preferences FROM users WHERE id = \$1',
'SELECT preferences FROM users WHERE id = ?',
},
variables: [
Variable<int>(user)
@ -650,9 +668,11 @@ abstract class _$Database extends GeneratedDatabase {
switch (executor.dialect) {
SqlDialect.sqlite =>
'INSERT INTO friendships VALUES (?1, ?2, ?3) RETURNING *',
SqlDialect.postgres ||
_ =>
SqlDialect.postgres =>
'INSERT INTO friendships VALUES (\$1, \$2, \$3) RETURNING *',
SqlDialect.mariadb ||
_ =>
'INSERT INTO friendships VALUES (?, ?, ?) RETURNING *',
},
variables: [
Variable<int>(var1),

View File

@ -33,9 +33,16 @@ void crudTests(TestExecutor executor) {
final db = Database(executor.createConnection());
await expectLater(
db.into(db.users).insert(marcell),
throwsA(toString(
matches(RegExp(r'unique constraint', caseSensitive: false)))));
db.into(db.users).insert(marcell),
throwsA(
toString(anyOf(
// sqlite3 and postgres
matches(RegExp(r'unique constraint', caseSensitive: false)),
// mariadb
matches(RegExp(r'duplicate entry', caseSensitive: false)),
)),
),
);
await executor.clearDatabaseAndClose(db);
});
@ -98,15 +105,16 @@ void crudTests(TestExecutor executor) {
final db = Database(executor.createConnection());
// ignore: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
if (db.executor.dialect == SqlDialect.postgres) {
await db.customStatement(
'INSERT INTO friendships (first_user, second_user) VALUES (@1, @2)',
<int>[1, 2]);
} else {
await db.customStatement(
'INSERT INTO friendships (first_user, second_user) VALUES (?1, ?2)',
<int>[1, 2]);
}
await db.customStatement(
switch (db.executor.dialect) {
SqlDialect.postgres =>
r'INSERT INTO friendships (first_user, second_user) VALUES ($1, $2)',
SqlDialect.mariadb =>
r'INSERT INTO friendships (first_user, second_user) VALUES (?, ?)',
_ =>
r'INSERT INTO friendships (first_user, second_user) VALUES (?1, ?2)',
},
<int>[1, 2]);
expect(await db.friendsOf(1).get(), isNotEmpty);
await executor.clearDatabaseAndClose(db);
@ -120,7 +128,8 @@ void crudTests(TestExecutor executor) {
Future<T?> evaluate<T extends Object>(Expression<T> expr) async {
late final Expression<T> effectiveExpr;
if (database.executor.dialect == SqlDialect.postgres) {
final dialect = database.executor.dialect;
if (dialect == SqlDialect.postgres || dialect == SqlDialect.mariadb) {
// 'SELECT'ing values that don't come from a table return as String
// by default, so we need to explicitly cast it to the expected type
// https://www.postgresql.org/docs/current/typeconv-select.html

View File

@ -9,6 +9,7 @@ packages:
- sqlparser
- examples/*
- extras/benchmarks
- extras/drift_mariadb
- extras/drift_postgres
- extras/encryption
- extras/integration_tests/*

View File

@ -28,6 +28,10 @@ class NodeSqlBuilder extends AstVisitor<void, void> {
return isKeywordLexeme(lexeme);
}
String escapeIdentifier(String identifier) {
return '"$identifier"';
}
@override
void visitAggregateFunctionInvocation(
AggregateFunctionInvocation e, void arg) {
@ -1343,7 +1347,7 @@ class NodeSqlBuilder extends AstVisitor<void, void> {
void identifier(String identifier,
{bool spaceBefore = true, bool spaceAfter = true}) {
if (isKeyword(identifier) || _notAKeywordRegex.hasMatch(identifier)) {
identifier = '"$identifier"';
identifier = escapeIdentifier(identifier);
}
symbol(identifier, spaceBefore: spaceBefore, spaceAfter: spaceAfter);

View File

@ -1,26 +0,0 @@
#!/usr/bin/env bash
pushd extras/drift_postgres
echo "Running integration tests with Postgres"
dart pub upgrade
dart test
popd
pushd examples/with_built_value
echo "Running build runner in with_built_value"
dart pub upgrade
dart run build_runner build --delete-conflicting-outputs
popd
pushd examples/modular
echo "Running build runner in modular example"
dart pub upgrade
dart run build_runner build --delete-conflicting-outputs
dart run bin/example.dart
popd
pushd examples/migrations_example
echo "Testing migrations in migrations_example"
dart pub upgrade
dart test
popd