Skip to content

[10.x] Add Lateral Join to Query Builder #50050

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

Merged
merged 2 commits into from
Feb 25, 2024
Merged

Conversation

Bakke
Copy link
Contributor

@Bakke Bakke commented Feb 12, 2024

This PR adds lateral join methods to the query builder. I have based the methods and tests upon joinSub as the behaviour is somewhat similar.

Lateral joins have been supported in PostgreSQL for a long time (since 9.3), and are also supported in MySQL since 8.0.14.

The lateral joins are a bit similar to a subquery join, as the right-hand table is expressed as a subquery. However the right-hand expression is executed for every row, which opens up for new use cases. It is particularly useful for joining in the top 'N' rows related to the main query. In example:

select * from "users" 
join lateral (
    select * from "contacts" 
    where "contracts"."user_id" = "users"."id"
    order by "contracts"."value" desc
    limit 5
) as "sub" 
    on true

@Bakke Bakke changed the title Query Builder Lateral Join [10.x] Query Builder Lateral Join Feb 12, 2024
@Bakke Bakke changed the title [10.x] Query Builder Lateral Join [10.x] Add Lateral Join to Query Builder Feb 12, 2024
@taylorotwell
Copy link
Member

I think we would normally want to keep that translation to "lateral" SQL syntax in the query "grammar" layer.

@taylorotwell taylorotwell marked this pull request as draft February 12, 2024 16:52
@Bakke
Copy link
Contributor Author

Bakke commented Feb 12, 2024

@taylorotwell Yes, that is probably best.
Since "lateral" is not a join type, and should be placed after the "join" keyword, it might be necessary with an extra property in the JoinClause class. Either a string, or a $lateral boolean.
The "on true" translation should probably also go into the "grammar" layer.

@crissi
Copy link
Contributor

crissi commented Feb 12, 2024

SQL server has Cross Apply And Outer Apply.

CROSS APPLY = lateral join
Outer Apply = left lateral join

Don't think it supports right lateral join

https://www.mssqltips.com/sqlservertip/1958/sql-server-cross-apply-and-outer-apply/

@Bakke
Copy link
Contributor Author

Bakke commented Feb 12, 2024

@crissi Postgres and MySQL does not support right lateral join neither (it would make no sense). My mistake. Removing it from the PR now.

I will look into translating it into cross/outer apply for SQL server as well.

@Bakke Bakke force-pushed the lateraljoin branch 2 times, most recently from 030f769 to 8e589c5 Compare February 13, 2024 13:42
@Bakke
Copy link
Contributor Author

Bakke commented Feb 13, 2024

I have moved "lateral" and "on true" translation into the grammar layer now, and added support for SQL server. I have tested joinLateral with PostgreSQL 16, MySQL 8 and SQL server 2022 in a samle app.

@taylorotwell I am a bit uncertain of two things in the PR:

  • I added a new boolean $lateral to the JoinClause class (with default false). This feels a bit dirty. Any better suggestions?
  • I added a joinLateral to the grammar layer, which accepts the current query builder and current join clause. This leads to code duplication for generating $tableAndNestedJoins, but provides more flexaility for each language translation. However it might be better to just pass the compiled $tableAndNestedJoins as a string, instead of the whole join clause?

@Rizky92
Copy link
Contributor

Rizky92 commented Feb 14, 2024

Just wanna chime in because MariaDB hasn't added support for lateral join yet. And Laravel considers MySQL and MariaDB as same RDBMS under mysql driver.

@Bakke
Copy link
Contributor Author

Bakke commented Feb 14, 2024

Just wanna chime in because MariaDB hasn't added support for lateral join yet. And Laravel considers MySQL and MariaDB as same RDBMS under mysql driver.

I know. MariaDB uses the same translations as MySQL, so it will fail with a syntax error as it is not supported yet. I do believe they are planning on implementing support though.

@Bakke Bakke force-pushed the lateraljoin branch 2 times, most recently from 1f9a007 to 0d0539f Compare February 15, 2024 16:28
@Bakke Bakke marked this pull request as ready for review February 15, 2024 16:29
@staudenmeir
Copy link
Contributor

staudenmeir commented Feb 15, 2024

👍 for this feature. @tpetry and I were just talking about it last week. We can also use lateral joins to improve the performance of some of Laravel's own queries.

Tobias has already implemented lateral joins for PostgreSQL in his laravel-postgresql-enhanced package and I would take inspiration from his code:

I also think it would be good to have integration tests (under tests/Integration/Database).

@Bakke
Copy link
Contributor Author

Bakke commented Feb 15, 2024

@staudenmeir Good tip! I will checkout this package in detail and draw some inspiration from there.

  • I agree that joinSubLateral etc. are more consistent. I will change the method names.
  • Tobias' implementation would put the translation to "lateral" SQL syntax back into the query builder, and out of the grammar layer (where @taylorotwell requested it should be). This would make it more difficult to translate to cross apply / outer apply for SQL server in at meaningful way. I agree that the new $lateral parameter isn't ideal, but I have not found a better way of passing "lateral" to the grammar layer (as it is independent of the join $type). Any suggestions on how to solve this are welcome.
  • I thought of custom join constraints, instead of only on true. I didn't think they would be used, so I decided to drop the extra parameters for the methods. I can add support for this with a default to on true.

@Bakke
Copy link
Contributor Author

Bakke commented Feb 16, 2024

@staudenmeir I am looking into custom join constraints (on a.foo = b.bar). SQL servers cross apply / outer apply don't support this, so these constraints will cause an error (or will have to be removed).

I tested lateral joins with custom constraints outside the subquery in Postgres. The results are the same as when I put the constraints inside the subquery. Performance also seems to be about the same.

Are there any use cases where putting the constraints outside the subquery provides an advantage? If not, I am afraid of just adding in unnecessary complexity that can cause confusion.

@tpetry
Copy link
Contributor

tpetry commented Feb 16, 2024

There's not really any benefit of using a join condition compared to just always using ON true. But I am not sure anymore whether there was a reason for the join condition for the lateral left join. Or whether i just did it that way to fit to the other join methods.

Would be great if you could build the API to be the same as mine (when using the same name) to not break my package. You could e.g. only use the first two params (and always do ON true). The current implementation would break my implementation.

@Bakke
Copy link
Contributor Author

Bakke commented Feb 16, 2024

@tpetry I am leaning against dropping the join conditions then, to keep it simple and consistent across different SQL implementations.

We have three choices to not break your current implementation in tpetry/laravel-postgresql-enhanced:

  • Keep the parameters, but ignore them and always do on true (I would like to avoid that)
  • Revert to my original method names, so they don't collide (joinLateral and leftJoinLateral)
  • Merge this against the 11.x branch (instead of 10.x) and do a breaking change

@tpetry
Copy link
Contributor

tpetry commented Feb 16, 2024

I would suggest these three methods:

  • crossJoinSubLateral($query, string $as)
  • leftJoinSubLateral($query, string $as)

They fit to my implementation as I only add some parameters - which I believe are not even usefull. The joinSubLateral implementation of you with type = inner could even be dropped as it doesn't add anything. A lateral join is always used in a cross join behaviour, either by using a cross join (with parameters in the subquery) or by using a join condition.

So it would make the concept more easy by saying that the join target is always a subquery that needs to apply the filters and all results are used - as expected.

@Bakke
Copy link
Contributor Author

Bakke commented Feb 16, 2024

@tpetry @staudenmeir I suggest reverting to joinLateral and leftJoinLateral, to keep the methods separate from the laravel-postgresql-enhanced package. That way, users can decide to switch from the package methods to builtin methods.

If I drop the joinSubLateral implementation with $type = 'inner' I will have to duplicate code in leftJoinSubLateral. I don't see a good way to keep joinSubLateral without breaking the package (I would have to add unused parameters), so I would rather rename it.

A lateral join in its simplest form is join lateral (sql subquery here) as "q" on true) (inner join). I think joinLateral reflects this in a simple way. joinSubLateral is a bit more descriptive, but collides with the package.

I would also like to avoid using "cross" in the method names, unless we explicitly include a cross join method. A lateral join is always used in a cross join behaviour, but you would probably write the SQL as join lateral or inner join lateral. I think a separate cross join method will be redundant.

In short: I think dropping "Sub" from the method names is a fair trade-off for not breaking the package, and not including any unused parameters.

@tpetry
Copy link
Contributor

tpetry commented Feb 19, 2024

Renaming to not break my package would be nice 👍

But I still don't get the $type = 'inner' param of the query. Is that to supply a left value? A specific method for that would match more to the other query builder methods.

@Bakke
Copy link
Contributor Author

Bakke commented Feb 19, 2024

Renaming to not break my package would be nice 👍

But I still don't get the $type = 'inner' param of the query. Is that to supply a left value? A specific method for that would match more to the other query builder methods.

$type = 'inner' is to supply the left value, yes. The only difference between joinLateral and leftJoinLateral is the left value. This way leftJoinLateral can just call joinLateral, without duplicating any code:

return $this->joinLateral($query, $as, 'left');

The same is done for leftJoinSub.

@tpetry
Copy link
Contributor

tpetry commented Feb 21, 2024

  • Is it ok to revert to joinLateral and leftJoinLateral, instead of joinLateralSub, so we don't break the tpetry/laravel-postgresql-enhanced package?

Yes 😅

@Bakke
Copy link
Contributor Author

Bakke commented Feb 21, 2024

@staudenmeir I rewrote the code to use a JoinLateralClause class instead now.
@dkulyk I left this change as a separate commit. As a reviewer I thought you could decide if you like this solution better than the bool $lateral approach.

@Bakke Bakke requested a review from dkulyk February 21, 2024 11:44
@staudenmeir
Copy link
Contributor

Thanks. Can you please add integration tests? The queries should be tested against actual databases.

@Bakke
Copy link
Contributor Author

Bakke commented Feb 21, 2024

Thanks. Can you please add integration tests? The queries should be tested against actual databases.

Should I create separate LateralJoinTest.php for MySQL, Postgres and SQL server under tests/Integration/Database?

@staudenmeir
Copy link
Contributor

Yes, please.

@Bakke Bakke force-pushed the lateraljoin branch 4 times, most recently from abf8b2f to 08b7b32 Compare February 21, 2024 16:08
@Bakke
Copy link
Contributor Author

Bakke commented Feb 21, 2024

@staudenmeir I added some simple integration tests now. They are skipped for MySQL < 8.0.14 and MariaDB.

@Bakke Bakke requested a review from staudenmeir February 21, 2024 16:13
@staudenmeir
Copy link
Contributor

Excellent, thanks. From my side, the PR is ready (I can't approve it).

@Bakke
Copy link
Contributor Author

Bakke commented Feb 21, 2024

@staudenmeir Great, thanks!
Marking it as ready for review so @taylorotwell can look over it as well.

@Bakke Bakke marked this pull request as ready for review February 21, 2024 17:00
@Bakke Bakke force-pushed the lateraljoin branch 2 times, most recently from 6f2ce74 to 5d54847 Compare February 21, 2024 17:11
@Bakke
Copy link
Contributor Author

Bakke commented Feb 21, 2024

@staudenmeir I made a small change to the integration tests. limit is changed to 2 and assertCount is used to check the number of returned rows. I think it makes more sense to test that limit actually can control the number of returned rows (main query rows X subquery rows).

@taylorotwell taylorotwell merged commit 5f8684b into laravel:10.x Feb 25, 2024
@taylorotwell
Copy link
Member

Thanks!

@Bakke Bakke deleted the lateraljoin branch February 26, 2024 08:42
@taylorotwell
Copy link
Member

@Bakke can you send a PR to laravel/docs for this?

@Bakke
Copy link
Contributor Author

Bakke commented Feb 27, 2024

@taylorotwell Yes, of course! I will create the PR as soon as possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants