-
Notifications
You must be signed in to change notification settings - Fork 640
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
Typings revised for passing result type down the query builder chain #744
Conversation
2 similar comments
tests/ts/examples.ts
Outdated
qb = qb.runBefore(async (result: any, builder: objection.QueryBuilder<Person>) => {}); | ||
qb = qb.runAfter(async (result: Person[], builder: objection.QueryBuilder<Person>) => {}); | ||
qb = qb.runBefore(async (result: any, builder: objection.QueryBuilder<Person, Person[]>) => {}); | ||
qb = qb.runAfter(async (result: Person[], builder: objection.QueryBuilder<Person, Person[]>) => {}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One should return stuff from these. Whatever you return from runBefore
gets passed to the next runBefore
function as the first argument. That's impossible write typings for, but return value should be required.
Whatever you return from runAfter
ends up being the return value of the query (in addition to being the input for the next runAfter
). This we probably could write typings for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wondered about that. I'll see what I can do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, it seems that the purpose of these methods is to allow code to do an out-of-order prepending or appending of query actions to the query chain.
If the query chain were written expecting runBefore
to change the result type that the first query method acts on, the type for the first method in the chain would be wrong, and the dev would have to typecast it. The typings only work if the final output of the runBefore
series has the same type as the input to the first query method, which is now Model | Model[]
.
I'm unable to find a way to incorporate runAfter
result types. It would require adding a fourth type parameter to QueryBuilder and passing it from query builder to query builder through the typings. Call this type AV
. We'd want execute
to yield type AV
when defined and RV
otherwise, but AFAIK, Typescript cannot conditionally select among types. We could initialize AV
to never
so that RV | AV
evaluates to RV
, but RV | AV
would never evaluate to just AV
because RV
would never be never
.
Unless there's a Typescript trick for doing this that I'm unaware of.
tests/ts/examples.ts
Outdated
const findOneWhereInThrow: Promise<Person> = Person.query() | ||
.findOne('raw sql') | ||
.whereIn('status', ['active', 'pending']) | ||
.throwIfNotFound(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You cannot give a string for findOne
. Also none of the methods accept raw sql as a string. Either objection.raw
or knex.raw
needs to be used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if it should be explicitly prevented, but having these examples is misleading.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll change the findOne()
parameters to something valid. They don't matter for the purpose of this test, so might as well pick something that's possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Corrected in the new push.
type ColumnRef = string | Raw | Reference | QueryBuilder<any, any[]>; | ||
type TableName = string | Raw | Reference | QueryBuilder<any, any[]>; | ||
|
||
interface QueryInterface<QM, RM, RV> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know QueryInterface
was not added in this commit, but I've always wondered what's the purpose of it? Only one class ever implements it. Couldn't we just move these methods over to QueryBuilder
and eliminate the whole interface?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are the QueryInterface methods all borrowed from Knex, while the QueryBuilder methods added by Objection? If so, it might help with maintenance to keep them separate. But then we might want to rename QueryInterface to make that clearer.
I do find the names QueryBuilder and QueryBuilderBase confusing. Both names suggest that they're the top-most query builder interface, but QueryBuilder extends QueryBuilderBase. At least in this new merging of query builders, every QueryBuilder instance implements QueryBuilderBase, so we could merge these two interfaces.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe QueryInterface and QueryBuilder could be coalesced.
With respect to the name, when I'd initially done the typings, I had to copy over the knex typings for QueryBuilder/QueryInterface into objection to extend them with generics (and I still feel bad about that). I've manually synced them a couple times, but I'd love to not need to do that.
What would be ideal is if this copypasta could go away, and use the knex typings directly, but that would require the diff I'd made be incorporated into the knex typings. @koskimas do you think that'd be a welcome change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mceachen, what to you think of combining QueryBuilder and QueryBuilderBase, per my comment above?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you can pull it off and not break existing tests, go for it: less is more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I got answers. The QueryBuilder and QueryBuilderBase types may correspond to classes of those names in the implementation. If these types do indeed correspond to these classes, we may want to keep the names the same to facilitate maintenance.
OTH, as a person perusing just the public API -- including the typings -- I think I'd prefer calling QueryBuilderBase and QueryBuilder by the names of AbstractQueryBuilder and QueryBuilder, to tell me their roles and relationship at a glance. (QueryBuilder could be the interface for QueryBuilderBase, or QueryBuilderBase could be the base (class?) of QueryBuilder. It's ambiguous to me.)
Also, I was unable to merge these two interfaces because the return types of throwIfNotFound()
cannot be generalized more strictly than any
.
I did however move returning()
and runAfter()
to QueryBuilderBase. I think runAfter()
may have originally been query-builder-specific in order to map the output of the query builder to the input of runAfter()
. But because runAfter()
callbacks can be chained, we can't hardcode its input type. I changed it to any
.
tests/ts/examples.ts
Outdated
const deleteByIDThrow: Promise<number> = Person.query() | ||
.deleteById(32) | ||
.throwIfNotFound(); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there tests for these cases:
// first turns array result into single
const x1: Promise<Model> = qb.where().first();
// the location of `first` doesn't matter
const x2: Promise<Model> = qb.first().where();
// returning turns `number` result back to `M[]`
const x3: Promise<Model[]> = qb.where().patch().returning();
// the position of `returning` doesn't matter (as long as it comes after the method that turns type into non-model)
const x4: Promise<Model> = qb.delete().returning().first().where();
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll check and add them if not. I want more returning()
test cases as well. We put returning()
on the base query builder, so it's always accessible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm noticing that my new examples are only testing the static methods. I wonder if I should duplicate them all for the instance methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd tried to hit both in the past. If you could add them, that'd be great.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added them in the new push, plus a few more of the returning()
variety, as well as their static method equivalents.
All tests continue to pass.
BTW, your x1, x2, and x4 tests should return Promise<Model | undefined>
.
I'm thinking I should duplicate all my static method tests as instance method tests. I think it has to be code duplication because the testing occurs at tsc compile time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, you're right, it has to be duplicated code. Perhaps adding a comment to each section saying why the duplication is happening will somewhat appease the Engineers of Tomorrow when they judge us for our code duplication sins?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
appease the Engineers of Tomorrow when they judge us for our code duplication sins?
LOL! Will do!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we don't need to dup many tests. After the first call to Model.query()
or model.$query()
, we're testing the same query builder methods. I'm thinking that we can provide coverage either way and then throw in a few token tests for other methods of getting query builders. The only internals we're taking advantage of is our knowing that they're all using the same query builder types.
Less code duplication makes for easier maintenance. And less judging! What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whatever you do, I'd suggest leaving a comment in the code explaining why there is either code duplication or a lack of duplicated instance/static tests. I think that will cover our butts from future judging.
In general, I'll always lean towards less code, if we can get away with it.
tests/ts/examples.ts
Outdated
.runAfter(async (result: any, builder: objection.QueryBuilder<Person>) => 88); | ||
const runAfterPersons: Promise<Person[]> = qb.runAfter( | ||
async (result: any, builder: objection.QueryBuilder<Person, Person[]>) => 88 | ||
); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@koskimas, perhaps you can review these runBefore/runAfter tests? I'm mainly ensuring that these methods do not affect the query builder type chain, at least not in-place.
let instanceQB = person.$query(); | ||
instanceQB = staticQB; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mceachen, I think I managed to avoid code duplication entirely, as well as make no assumptions about implementation. What do you think of this?
@mceachen, I experimented with defaulting query builders to using I made a |
@koskimas I'll have time to review this tomorrow, can you hold off on releasing 1.0.0 until tomorrow afternoon? It'd be nice if we got this refactoring into 1.0.0. |
Also, I may have a way to improve type support of model fields -- using partials in cases where models are currently typed whole but are actually partially returned, not using partials in cases where they are currently typed partial when build-time-required model fields are actually required. I might be able to do it in a backward-compatible way, but it's not entirely clear to me yet. It would be nice to hold off on 1.0 to let me continue working with the typings without feeling hamstrung for backward-compatibility. I'm racing as fast as I can. UPDATE: And I think I can provide most of the custom query builder typings solution, only requiring the user to do a little work that Typescript generics won't provide out-of-the box. Here are examples of the Partial issues that I think I can fix: class Person extends Model {
firstName: string;
lastName: string;
age?: number;
static get jsonSchema () {
return {
type: 'object',
required: ['firstName', 'lastName'],
};
}
}
// This should error at build time because lastName is missing.*
Person.query().insert({firstName: 'Mo'});
Person.query().update({firstName: 'Mo'}).returning('age').then(peeps => {
// This should warn at build time that lastName may be undefined.**
const lastName: string = peeps[0].lastName;
}); * - My experiments show that I can make this behave the way it currently does by default. These latter mods should be a new PR -- after this one -- so we can evaluate them separately. |
@jtlapp I've been trying out this PR against my personal project, and the only issue I have is QueryBuilder's default of I also tried removing the @koskimas, @jtlapp do you think it's reasonable to make QueryBuilder default return an array? |
I'm also trying to flip the RM, RV generics so you don't have to do |
@mceachen, I have all that fixed in the model-array-default branch, per my above comment. The only thing not backward-compatible in that branch is |
And yes, I agree that the default should be a model array, given that you have to go out of your way in the query builder chain to get anything else. The only drawback is that you have to do the unexpected parameterization |
If you want, I can push the model array default solution to this PR, so that we're evaluating that or no pass-through-result-types at all. |
@mceachen and I discussed supporting partials. It seems like a reasonable thing to do, but possibly non-trivial to do right. We're punting for 1.0. I think we're done with this investigation. |
Typings revision per our conversation, @mceachen, for #742. Also had to prettier it up.
I think we left one topic unresolved: defaulting
QueryBuilder<M>
to yielding anM[]
instead of anM
, as things stand.Not the problem:
I think the main problem with changing it toM[]
is that, because we can only default unspecified parameters on the right, we'd end up specifying all the parametersQueryBuilder<RM, RV, QB>
orQueryBuilder<RM, QB, RV>
in many typings occurrences that are now just singlets or couplets. PuttingRV
last would reduce that some, but it might be a bit confusing to see the type in the formQueryBuilder<return, queried, return>
, since the return types aren't adjacent.Possible problem: We can infer
M[]
fromM
but notM
fromM[]
.UPDATE: I created an experimental branch offshoot of this PR that defaults to
Model[]
, but I don't have$relatedQuery()
working.