-
Notifications
You must be signed in to change notification settings - Fork 351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Read references from manager queries #2993
base: develop
Are you sure you want to change the base?
Conversation
The code generator for this will be an abysmal mess until the api design for this is ironed out |
After some investigation, it turns you there is no way to read multiple column of a reverse reference in a single query |
This comment was marked as resolved.
This comment was marked as resolved.
The API still looks good to me, but I think there should be a way to be more explicit about the impact of joining a reverse reference. Things that suddenly have the potential to make queries much slower should be more obvious than calling Also, one thing we should consider is the size of generated code (drift is already generating a fair bit of code, and increasing this further has costs on things like analyzer performance). Users are also typically not too happy about generated code they don't need. Code generators are also harder to understand than library code, so the maintenance cost for us is higher. So we should check whether there are ways to simplify this, such as:
For 2., a possible API I didn't think through at all could perhaps roughly look like this: class TodoItems extends Table {
// ... column definitions
@JoinQuery()
SomeQueryType<(TodoItem, Category)> withCategory;
} And then we'd only generate a manager for the These are just early ideas though, there are probably other approaches to reduce some of the complexity in the generator. |
I've spent a considerable amount of time on this, but I'll have to agree, in practice this is handing a loaded gun to a child. final category =await category.filter((f)=> f.id(5)).withReferences().getSingle()
todos = await category.references.todos.get() The question is how regular joins should be handled. Option 1
I'm tempted to have The drawback is that they are forced to all or none, which lowers performance, especially with Option 2
Only include the requested dataclasses, this is better in every other way, except
Thoughts? |
Yeah, unfortunately this looks like a hard problem to get right, and since nobody likes to rewrite all their queries we can't really change the API substantially once we have released something.
I agree with that API 👍 A downside would be that we then can't automatically run the 1+n statements in a transaction. But I'm fine with that, users will know that they're separate queries because they have to use a second await and so it's expected that the two queries aren't atomic unless manually wrapped in a transaction. Regarding option one: How terrible do you think it is from a UX perspective if we just make references nullable by default and then have boolean flags to control what to include? E.g.
I'm fine with back- and forward-references acting differently. And maybe that's something that's not ever going to come up, but I still want to ask whether we can make this possible. What if we have unbounded chains of references? E.g. something contrived like class Employees extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get supervisor => integer().references(Employees, #id)();
} In SQL, I could write a query to find the supervisor two layers up in the hierarchy. I think it would be cool if we could have an API where that can be expressed, but it will likely look a lot different than the things we (and probably most other ORMs) have discussed. abstract class Employees extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get supervisor => integer().references(Employees, #id)();
JoinQuery<({self: Employee, another: Employee})> get withGrandSupervisor => buildJoin(['self', 'another: self.supervisor.supervisor']);
} I don't want to derail the discussion with these examples (and I'll shut up about them if it sounds like no one will ever need this), but I think that's an opportunity to not become a limitation (every join possible in SQL is possible here), not generate superfluous code (joins have to be requested), and be precise about which tables are included in the result. abstract class Employees extends Table {
IntColumn get id => integer().autoIncrement()();
@GenerateJoin
IntColumn get supervisor => integer().references(Employees, #id)();
} |
…sses Support custom row classes for manager
I'm still thinking about how to proceed on this... |
It's not an easy API to design for sure. My "list all joins beforehand" idea also suffers from poor discoverability. I'm happy to help if as well if you have some rough ideas for an API and how the implementation could look like of course. |
Let me walk the logic: Requirements We should only prefetch what a user explicitly asks for. The todos.withReferences().withUser().getSingle().references.user // Returns User
todos.withReferences().getSingle().references.user // Returns Future<User> OR Selectable<User> The above should work with back references too. The only issue that we are faced with is streaming. If we support the above for streaming, then it is possible that the user can create a stream that is tied to every table in the database. todos.withReferences().withUser().stream() // Re-trigger on every write to `todos` or `users` On the other hand, if we don't add this feature, users may do the following final results = todos.stream.asyncMap((e) async => (todos: e, user: await users.filter((f)=>f.id(e.user)).getSingleOrNull()))
results.first.todo // The 1st todo
results.first.user // & it's first user This means that there is a stream for every object being returned, this is really bad. In a perfect world the user should only be streaming what he needs, in other words, pagination. What such an API could look like, I'm still thinking about... |
Pagination would definitely be awesome to have, but I think it doesn't really solve the stream invalidation problem (every write to either table would still invalidate every page in every paginated stream, right?). We'd have to parse fewer rows into result classes, but ultimately not run fewer queries than before. That's not a problem directly related to a join API in my opinion - manual joins also suffer from the same problem. So a pagination API should be orthogonal to a join API and work with or without it (users may also want to paginate simple reads without references to a single table). |
In `data_class_writer.dart` we expect a mixin named `${table.entityInfoName}ToColumns`, but then we create one named `${table.baseDartName}ToColumns` in `table_writer.dart.
define `toColumnsMixin` String in `DriftElementWithResultSet` and reuse it in `DataClassWriter` and `TableWriter` to avoid future mismatches in mixin name.
Fix: toColumns mixin generated with wrong name
Add Name Clash Info To Docs
Mention minimum version in docs and use it in CI tests
The companion class makes an extremely useful way to bundle up a set of changes to make to a record. This copyWithCompanion method makes it possible to apply those changes directly to a record as represented in Dart, as an instance of the data class. For example, it can be used by a wrapper that keeps a write-through cache in Dart for a given database table. Here's slightly simplified from a real example we have in Zulip: /// Update an account in the store, returning the new version. /// /// The account with the given account ID will be updated. /// It must already exist in the store. Future<Account> updateAccount(int accountId, AccountsCompanion data) async { assert(!data.id.present); await (_db.update(_db.accounts) ..where((a) => a.id.equals(accountId)) ).write(data); final result = _accounts.update(accountId, (value) => value.copyWithCompanion(data)); notifyListeners(); return result; } It's possible to write such a method by hand, of course (in an extension), or to call copyWith directly. But it needs a line for each column of the table, which makes either of those error-prone: in particular, because copyWith naturally has its parameters all optional, it would be very easy for someone adding a new column to overlook the need to add a line to this method, and then updates to the new column would just silently get dropped. So this is a case where there's a large benefit to generating the code.
@simolus3 Let's have all references be accessible by final group1 = await groups.withReferences(users: true).getSingle(); // Get first group, loads users into cache
final users = await group1.users; // Get's users from the cache, doesn't make another query In previous examples, There isn't really a practical benefit from a performance perspective between awaiting |
Since you have to await the query either way I'm not sure it makes much of a difference in the end, most methods using the API will be |
🚧 WIP 🚧
The current syntax is as follows:
Everything is added with joins, so it's all well optimized
Code to try this out is in the manager testing code