Skip to content

Commit

Permalink
Support table-valued functions
Browse files Browse the repository at this point in the history
  • Loading branch information
simolus3 committed Sep 6, 2023
1 parent 4c2841d commit 84659e0
Show file tree
Hide file tree
Showing 11 changed files with 444 additions and 32 deletions.
80 changes: 80 additions & 0 deletions docs/lib/snippets/queries/json.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// #docregion existing
import 'dart:convert';

import 'package:drift/drift.dart';
import 'package:drift/extensions/json1.dart';
import 'package:json_annotation/json_annotation.dart';

// #enddocregion existing
import 'package:drift/native.dart';

part 'json.g.dart';

// #docregion existing
@JsonSerializable()
class ContactData {
final String name;
final List<String> phoneNumbers;

ContactData(this.name, this.phoneNumbers);

factory ContactData.fromJson(Map<String, Object?> json) =>
_$ContactDataFromJson(json);

Map<String, Object?> toJson() => _$ContactDataToJson(this);
}
// #enddocregion existing

// #docregion contacts
class _ContactsConverter extends TypeConverter<ContactData, String> {
@override
ContactData fromSql(String fromDb) {
return ContactData.fromJson(json.decode(fromDb) as Map<String, Object?>);
}

@override
String toSql(ContactData value) {
return json.encode(value.toJson());
}
}

class Contacts extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get data => text().map(_ContactsConverter())();

TextColumn get name => text().generatedAs(data.jsonExtract(r'$.name'))();
}
// #enddocregion contacts

// #docregion calls
class Calls extends Table {
IntColumn get id => integer().autoIncrement()();
BoolColumn get incoming => boolean()();
TextColumn get phoneNumber => text()();
DateTimeColumn get callTime => dateTime()();
}
// #enddocregion calls

@DriftDatabase(tables: [Contacts, Calls])
class MyDatabase extends _$MyDatabase {
MyDatabase() : super(NativeDatabase.memory());

@override
int get schemaVersion => 1;

// #docregion calls-with-contacts
Future<List<(Call, Contact)>> callsWithContact() async {
final phoneNumbersForContact =
contacts.data.jsonEach(this, r'$.phoneNumbers');
final phoneNumberQuery = selectOnly(phoneNumbersForContact)
..addColumns([phoneNumbersForContact.value]);

final query = select(calls).join(
[innerJoin(contacts, calls.phoneNumber.isInQuery(phoneNumberQuery))]);

return query
.map((row) => (row.readTable(calls), row.readTable(contacts)))
.get();
}
// #enddocregion calls-with-contacts
}
34 changes: 34 additions & 0 deletions docs/pages/docs/Advanced Features/joins.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,37 @@ joining this select statement onto a larger one grouping by category:
{% include "blocks/snippet" snippets = snippets name = 'subquery' %}

Any statement can be used as a subquery. But be aware that, unlike [subquery expressions]({{ 'expressions.md#scalar-subqueries' | pageUrl }}), full subqueries can't use tables from the outer select statement.

## JSON support

{% assign json_snippet = 'package:drift_docs/snippets/queries/json.dart.excerpt.json' | readString | json_decode %}

sqlite3 has great support for [JSON operators](https://sqlite.org/json1.html) that are also available
in drift (under the additional `'package:drift/extensions/json1.dart'` import).
JSON support is helpful when storing a dynamic structure that is best represented with JSON, or when
you have an existing structure (perhaps because you're migrating from a document-based storage)
that you need to support.

As an example, consider a contact book application that started with a JSON structure to store
contacts:

{% include "blocks/snippet" snippets = json_snippet name = 'existing' %}

To easily store this contact representation in a drift database, one could use a JSON column:

{% include "blocks/snippet" snippets = json_snippet name = 'contacts' %}

Note the `name` column as well: It uses `generatedAs` with the `jsonExtract` function to
extract the `name` field from the JSON value on the fly.
The full syntax for JSON path arguments is explained on the [sqlite3 website](https://sqlite.org/json1.html#path_arguments).

To make the example more complex, let's look at another table storing a log of phone calls:

{% include "blocks/snippet" snippets = json_snippet name = 'calls' %}

Let's say we wanted to find the contact for each call, if there is any with a matching phone number.
For this to be expressible in SQL, each `contacts` row would somehow have to be expanded into a row
for each stored phone number.
Luckily, the `json_each` function in sqlite3 can do exactly that, and drift exposes it:

{% include "blocks/snippet" snippets = json_snippet name = 'calls-with-contacts' %}
5 changes: 5 additions & 0 deletions drift/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 2.12.0-dev

- Add support for table-valued functions in the Dart query builder.
- Support `json_each` and `json_tree`.

## 2.11.1

- Allow using `.read()` for a column added to a join from the table, fixing a
Expand Down
115 changes: 115 additions & 0 deletions drift/lib/extensions/json1.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,119 @@ extension JsonExtensions on Expression<String> {
Variable.withString(path),
]).dartCast<T>();
}

/// Calls the `json_each` table-valued function on `this` string, optionally
/// using [path] as the root path.
///
/// This can be used to join every element in a JSON structure to a drift
/// query.
///
/// See also: The [sqlite3 documentation](https://sqlite.org/json1.html#jeach)
/// and [JsonTableFunction].
JsonTableFunction jsonEach(DatabaseConnectionUser database, [String? path]) {
return JsonTableFunction._(database, functionName: 'json_each', arguments: [
this,
if (path != null) Variable(path),
]);
}

/// Calls the `json_tree` table-valued function on `this` string, optionally
/// using [path] as the root path.
///
/// This can be used to join every element in a JSON structure to a drift
/// query.
///
/// See also: The [sqlite3 documentation](https://sqlite.org/json1.html#jeach)
/// and [JsonTableFunction].
JsonTableFunction jsonTree(DatabaseConnectionUser database, [String? path]) {
return JsonTableFunction._(database, functionName: 'json_tree', arguments: [
this,
if (path != null) Variable(path),
]);
}
}

/// Calls [json table-valued functions](https://sqlite.org/json1.html#jeach) in
/// drift.
///
/// With [JsonExtensions.jsonEach] and [JsonExtensions.jsonTree], a JSON value
/// can be used a table-like structure available in queries and joins.
///
/// For an example and more details, see the [drift documentation](https://drift.simonbinder.eu/docs/advanced-features/joins/#json-support)
final class JsonTableFunction extends TableValuedFunction<JsonTableFunction> {
JsonTableFunction._(
super.attachedDatabase, {
required super.functionName,
required super.arguments,
super.alias,
}) : super(
columns: [
GeneratedColumn<DriftAny>('key', alias ?? functionName, true,
type: DriftSqlType.any),
GeneratedColumn<DriftAny>('value', alias ?? functionName, true,
type: DriftSqlType.any),
GeneratedColumn<String>('type', alias ?? functionName, true,
type: DriftSqlType.string),
GeneratedColumn<DriftAny>('atom', alias ?? functionName, true,
type: DriftSqlType.any),
GeneratedColumn<int>('id', alias ?? functionName, true,
type: DriftSqlType.int),
GeneratedColumn<int>('parent', alias ?? functionName, true,
type: DriftSqlType.int),
GeneratedColumn<String>('fullkey', alias ?? functionName, true,
type: DriftSqlType.string),
GeneratedColumn<String>('path', alias ?? functionName, true,
type: DriftSqlType.string),
],
);

Expression<T> _col<T extends Object>(String name) {
return columnsByName[name]! as Expression<T>;
}

/// The JSON key under which this element can be found in its parent, or
/// `null` if this is the root element.
///
/// Child elements of objects have a string key, elements in arrays are
/// represented by their index.
Expression<DriftAny> get key => _col('key');

/// The value for the current value.
///
/// Scalar values are returned directly, objects and arrays are returned as
/// JSON strings.
Expression<DriftAny> get value => _col('value');

/// The result of calling [`sqlite3_type`](https://sqlite.org/json1.html#the_json_type_function)
/// on this JSON element.
Expression<String> get type => _col('type');

/// The [value], or `null` if this is not a scalar value (so either an object
/// or an array).
Expression<DriftAny> get atom => _col('atom');

/// An id uniquely identifying this element in the original JSON tree.
Expression<int> get id => _col('id');

/// The [id] of the parent of this element.
Expression<int> get parent => _col('parent');

/// The JSON key that can be passed to functions like
/// [JsonExtensions.jsonExtract] to find this value.
Expression<String> get fullKey => _col('fullkey');

/// Similar to [fullKey], but relative to the `root` argument passed to
/// [JsonExtensions.jsonEach] or [JsonExtensions.jsonTree].
Expression<String> get path => _col('path');

@override
ResultSetImplementation<JsonTableFunction, TypedResult> createAlias(
String alias) {
return JsonTableFunction._(
attachedDatabase,
functionName: entityName,
arguments: arguments,
alias: alias,
);
}
}
11 changes: 1 addition & 10 deletions drift/lib/src/runtime/query_builder/components/subquery.dart
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,6 @@ class Subquery<Row> extends ResultSetImplementation<Subquery, Row>

@override
FutureOr<Row> map(Map<String, dynamic> data, {String? tablePrefix}) {
if (tablePrefix == null) {
return select._mapRow(data);
} else {
final withoutPrefix = {
for (final MapEntry(:key, :value) in columnsByName.entries)
key: data['$tablePrefix.$value']
};

return select._mapRow(withoutPrefix);
}
return select._mapRow(data.withoutPrefix(tablePrefix));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import 'dart:async';

import 'package:meta/meta.dart';

import '../../../dsl/dsl.dart';
import '../../api/runtime_api.dart';
import '../../utils.dart';
import '../query_builder.dart';

/// In sqlite3, a table-valued function is a function that resolves to a result
/// set, meaning that it can be selected from.
///
/// For more information on table-valued functions in general, visit their
/// [documentation](https://sqlite.org/vtab.html#tabfunc2) on the sqlite website.
///
/// This class is meant to be extended for each table-valued function, so that
/// the [Self] type parameter points to the actual implementation class. The
/// class must also implement [createAlias] correctly (ensuring that every
/// column has its [GeneratedColumn.tableName] set to the [aliasedName]).
///
/// For an example of a table-valued function in drift, see the
/// `JsonTableFunction` in `package:drift/json1.dart`. It makes the `json_each`
/// and `json_tree` table-valued functions available to drift.
@experimental
abstract base class TableValuedFunction<Self extends ResultSetImplementation>
extends ResultSetImplementation<Self, TypedResult>
implements HasResultSet, Component {
final String _functionName;

/// The arguments passed to the table-valued function.
final List<Expression> arguments;

@override
final DatabaseConnectionUser attachedDatabase;

@override
final List<GeneratedColumn<Object>> $columns;

@override
final String aliasedName;

/// Constructor for table-valued functions.
///
/// This takes the [attachedDatabase] (used to interpret results), the name
/// of the function as well as arguments passed to it and finally the schema
/// of the table (in the form of [columns]).
TableValuedFunction(
this.attachedDatabase, {
required String functionName,
required this.arguments,
required List<GeneratedColumn> columns,
String? alias,
}) : _functionName = functionName,
$columns = columns,
aliasedName = alias ?? functionName;

@override
Self get asDslTable => this as Self;

@override
late final Map<String, GeneratedColumn<Object>> columnsByName = {
for (final column in $columns) column.name: column,
};

@override
String get entityName => _functionName;

@override
FutureOr<TypedResult> map(Map<String, dynamic> data, {String? tablePrefix}) {
final row = QueryRow(data.withoutPrefix(tablePrefix), attachedDatabase);
return TypedResult(
const {},
row,
{
for (final column in $columns)
column: attachedDatabase.typeMapping
.read(column.type, row.data[column.name]),
},
);
}

@override
void writeInto(GenerationContext context) {
context.buffer
..write(_functionName)
..write('(');

var first = true;
for (final argument in arguments) {
if (!first) {
context.buffer.write(', ');
}

argument.writeInto(context);
first = false;
}

context.buffer.write(')');
}
}
Loading

0 comments on commit 84659e0

Please sign in to comment.