22
33namespace App \PostsQuery ;
44
5+ use App \Doctrine \InterpolateParametersSQLOutputWalker ;
56use App \DTO \PostKey \Reply as ReplyKey ;
67use App \DTO \PostKey \SubReply as SubReplyKey ;
78use App \DTO \PostKey \Thread as ThreadKey ;
89use App \Helper ;
9- use Doctrine \DBAL \ParameterType ;
10+ use Doctrine \DBAL \Query \QueryBuilder as DBALQueryBuilder ;
11+ use Doctrine \DBAL \Query \UnionType ;
12+ use Doctrine \ORM \AbstractQuery ;
1013use Doctrine \ORM \Query \Expr \Comparison ;
11- use Doctrine \ORM \Query \Parameter ;
14+ use Doctrine \ORM \Query \Parser ;
15+ use Doctrine \ORM \Query \ResultSetMapping ;
1216use Doctrine \ORM \QueryBuilder ;
1317use Illuminate \Support \Collection ;
1418use Symfony \Component \DependencyInjection \ParameterBag \ContainerBagInterface ;
3236
3337 public ?string $ nextCursor ;
3438
35- public Collection $ queries ;
39+ public array $ query ;
3640
3741 public function __construct (
3842 private Stopwatch $ stopwatch ,
@@ -41,23 +45,12 @@ public function __construct(
4145 private int $ perPageItems = 50 ,
4246 ) {}
4347
44- /** @return array{result: Collection, hasMorePages: bool, query: string, queryPlan: array} */
45- public function getQueryResult (QueryBuilder $ queryBuilder , int $ limit ): array
48+ /** @return array{result: Collection, hasMorePages: bool, queryPlan: array} */
49+ public function getQueryResult (AbstractQuery $ query , int $ limit ): array
4650 {
4751 $ maxResults = $ limit + 1 ;
48- $ query = $ queryBuilder ->setMaxResults ($ maxResults )->getQuery ();
49-
50- $ entityManager = $ query ->getEntityManager ();
51- $ connection = $ entityManager ->getConnection ();
52- $ parameters = collect ($ query ->getParameters ())->mapWithKeys (static fn (Parameter $ p ) => [
53- ': ' . $ p ->getName () => match ($ p ->getType ()) {
54- ParameterType::STRING => $ connection ->getDatabasePlatform ()->quoteStringLiteral ($ p ->getValue ()),
55- default => $ p ->getValue (),
56- }
57- ]);
58- $ rawSQL = $ entityManager ->createQuery (strtr ($ query ->getDQL (), $ parameters ->toArray ()))->getSQL ();
59- $ explainJSON = \Safe \json_decode ($ connection ->executeQuery (
60- 'EXPLAIN (COSTS, VERBOSE, BUFFERS, FORMAT JSON) ' . $ rawSQL . " LIMIT $ maxResults "
52+ $ explainJSON = \Safe \json_decode ($ query ->getEntityManager ()->getConnection ()->executeQuery (
53+ 'EXPLAIN (COSTS, VERBOSE, BUFFERS, FORMAT JSON) ' . $ query ->getSQL ()
6154 )->fetchOne (), true );
6255 $ plansCost = array_sum (array_map (static fn (array $ plan ) => $ plan ['Plan ' ]['Total Cost ' ], $ explainJSON ));
6356 $ planCostLimit = $ this ->containerBag ->get ('app.query_plan_cost_limit ' );
@@ -73,7 +66,6 @@ public function getQueryResult(QueryBuilder $queryBuilder, int $limit): array
7366 return [
7467 'result ' => $ result ,
7568 'hasMorePages ' => $ hasMorePages ?? false ,
76- 'query ' => $ rawSQL ,
7769 'queryPlan ' => $ explainJSON
7870 ];
7971 }
@@ -94,16 +86,19 @@ public function setResult(
9486 // remove query for post type with an empty encoded cursor ',,'
9587 $ queries = $ queries ->intersectByKeys ($ cursorsKeyByPostType );
9688 }
89+ $ maxResults = $ this ->perPageItems + 1 ;
9790
98- $ queries ->each (function (QueryBuilder $ qb , string $ postType ) use ($ isOrderByDesc , $ orderByField , $ cursorsKeyByPostType ) {
99- $ qb ->addOrderBy ("t. $ orderByField " , $ isOrderByDesc === true ? 'DESC ' : 'ASC ' )
91+ $ queries ->each (function (QueryBuilder $ qb , string $ postType ) use ($ maxResults , $ isOrderByDesc , $ orderByField , $ cursorsKeyByPostType ) {
92+ $ qb ->addSelect ("t. $ orderByField AS orderByField " )
93+ ->addOrderBy ("t. $ orderByField " , $ isOrderByDesc === true ? 'DESC ' : 'ASC ' )
10094 // cursor paginator requires values of orderBy column are unique
10195 // if not it should fall back to other unique field (here is the post ID primary key)
10296 // https://use-the-index-luke.com/no-offset
10397 // https://mysql.rjweb.org/doc.php/pagination
10498 // https://medium.com/swlh/how-to-implement-cursor-pagination-like-a-pro-513140b65f32
10599 // https://slack.engineering/evolving-api-pagination-at-slack/
106- ->addOrderBy ('t. ' . Helper::POST_TYPE_TO_ID [$ postType ]);
100+ ->addOrderBy ('t. ' . Helper::POST_TYPE_TO_ID [$ postType ])
101+ ->setMaxResults ($ maxResults );
107102
108103 $ cursors = $ cursorsKeyByPostType ->get ($ postType , collect ());
109104 if ($ cursors ->isEmpty ()) {
@@ -118,16 +113,12 @@ public function setResult(
118113 $ cursors ->mapWithKeys (fn ($ fieldValue , string $ fieldName ) =>
119114 $ qb ->setParameter ("cursor_ $ fieldName " , $ fieldValue )); // prevent overwriting existing param
120115 });
121-
122- $ results = $ queries ->map (fn (QueryBuilder $ query ) =>
123- self ::getQueryResult ($ query , $ this ->perPageItems ));
124- $ results = $ queries ->map (fn (QueryBuilder $ queryBuilder ) =>
125- $ this ->getQueryResult ($ queryBuilder , $ this ->perPageItems ));
126- /** @var PostsKeyByTypePluralName $postsKeyByTypePluralName */
127- $ postsKeyByTypePluralName = $ results
128- ->mapWithKeys (fn (array $ tuple , string $ postType ) =>
129- [Helper::POST_TYPE_TO_PLURAL [$ postType ] => $ tuple ['result ' ]]);
130- Helper::abortAPIIf (40401 , $ postsKeyByTypePluralName ->every (static fn (Collection $ i ) => $ i ->isEmpty ()));
116+ [
117+ 'unionOfQueriesSQL ' => $ unionOfQueriesSQL ,
118+ 'postsKeyByTypePluralName ' => $ postsKeyByTypePluralName ,
119+ 'hasMorePages ' => $ hasMorePages ,
120+ 'queryPlan ' => $ queryPlan
121+ ] = $ this ->getUnionQueryResult ($ queries , $ isOrderByDesc , $ maxResults );
131122
132123 $ this ->threads = $ postsKeyByTypePluralName ->get ('threads ' , collect ());
133124 $ this ->replies = $ postsKeyByTypePluralName ->get ('replies ' , collect ());
@@ -136,16 +127,92 @@ public function setResult(
136127 ?? $ this ->replies ->first ()->fid
137128 ?? $ this ->subReplies ->first ()->fid ;
138129 $ this ->currentCursor = $ cursorParamValue ?? '' ;
139- $ this ->nextCursor = $ results ->pluck ('hasMorePages ' )
140- ->contains (static fn (bool $ hasMorePages ) => $ hasMorePages )
130+ $ this ->nextCursor = $ hasMorePages
141131 ? $ this ->cursorCodec ->encodeNextCursor ($ postsKeyByTypePluralName ->except (
142132 $ queryByPostIDParamsName ->map (static fn (string $ postID ) => Helper::POST_ID_TO_TYPE_PLURAL [$ postID ])
143133 ))
144134 : null ;
145- $ this ->queries = $ results ->mapWithKeys (fn (array $ tuple , string $ postType ) =>
146- [$ postType => ['query ' => $ tuple ['query ' ], 'plan ' => $ tuple ['queryPlan ' ]]]
147- );
135+ $ this ->query = ['query ' => $ unionOfQueriesSQL , 'plan ' => $ queryPlan ];
148136
149137 $ this ->stopwatch ->stop ('setResult ' );
150138 }
139+
140+ /**
141+ * @psalm-type UnionPostKey = array{
142+ * postType: 'reply'|'subReply'|'thread',
143+ * postId: int,
144+ * fid: int,
145+ * tid: int,
146+ * pid: int,
147+ * orderByField: mixed
148+ * }
149+ * @param Collection<Helpecr::POST_TYPE, QueryBuilder> $queries
150+ * @param bool $isOrderByDesc
151+ * @param int $maxResults
152+ * @return array{unionOfQueriesSQL: string, postsKeyByTypePluralName: PostsKeyByTypePluralName, hasMorePages: bool, queryPlan: array}
153+ */
154+ private function getUnionQueryResult (Collection $ queries , bool $ isOrderByDesc , int $ maxResults ): array
155+ {
156+ /** @var DBALQueryBuilder $unionOfQueries */
157+ // https://stackoverflow.com/questions/36959801/doctrine-orm-querybuilder-or-dbal-querybuilder
158+ $ unionOfQueries = $ queries ->reduce (function (?DBALQueryBuilder $ dbalQueryBuilder , QueryBuilder $ ormQueryBuilder ) {
159+ $ ormQuery = $ ormQueryBuilder ->getQuery ();
160+ $ ormQuery ->setHint (\Doctrine \ORM \Query::HINT_CUSTOM_OUTPUT_WALKER , InterpolateParametersSQLOutputWalker::class);
161+ $ sql = $ ormQuery ->getSQL ();
162+ if ($ dbalQueryBuilder === null ) {
163+ return $ ormQueryBuilder ->getEntityManager ()->getConnection ()
164+ ->createQueryBuilder ()->union ($ sql );
165+ }
166+ return $ dbalQueryBuilder ->addUnion ($ sql , UnionType::ALL );
167+ });
168+ $ firstQuery = $ queries ->first ();
169+
170+ /** @var array{key-of<UnionPostKey>, string} $firstQueryFieldAliases */
171+ // field name and aliases in the first query in a union will override any other queries in union
172+ $ firstQueryFieldAliases = array_flip ((new Parser ($ firstQuery ->getQuery ()))
173+ ->parse ()->getResultSetMapping ()->scalarMappings );
174+ $ unionOfQueries = $ unionOfQueries
175+ ->addOrderBy ($ firstQueryFieldAliases ['orderByField ' ], $ isOrderByDesc === true ? 'DESC ' : 'ASC ' )
176+ ->addOrderBy ($ firstQueryFieldAliases ['postId ' ])
177+ ->setMaxResults ($ maxResults );
178+ $ unionOfQueriesSQL = $ unionOfQueries ->getSQL ();
179+
180+ $ rsm = new ResultSetMapping ();
181+ foreach ($ firstQueryFieldAliases as $ fieldName => $ fieldAlias ) {
182+ $ rsm ->addScalarResult ($ fieldAlias , $ fieldName );
183+ }
184+
185+ ['result ' => $ result , 'hasMorePages ' => $ hasMorePages , 'queryPlan ' => $ queryPlan ] = $ this ->getQueryResult (
186+ $ firstQuery ->getEntityManager ()->createNativeQuery ($ unionOfQueriesSQL , $ rsm ),
187+ $ this ->perPageItems
188+ );
189+ /** @var PostsKeyByTypePluralName $postsKeyByTypePluralName */
190+ $ postsKeyByTypePluralName = $ result
191+ ->groupBy (static fn (/** @var UnionPostKey $unionPostKey */ array $ unionPostKey ) => $ unionPostKey ['postType ' ])
192+ ->mapWithKeys (static fn (Collection $ unionPostKeys , /** @var 'reply'|'subReply'|'thread' $postType */ string $ postType ) =>
193+ [Helper::POST_TYPE_TO_PLURAL [$ postType ] => $ unionPostKeys
194+ ->map (static function (/** @var UnionPostKey $unionPostKey */ array $ unionPostKey ) use ($ postType ) {
195+ [
196+ 'postId ' => $ postId ,
197+ 'fid ' => $ fid ,
198+ 'tid ' => $ tid ,
199+ 'pid ' => $ pid ,
200+ 'orderByField ' => $ orderByFieldValue
201+ ] = $ unionPostKey ;
202+ return match ($ postType ) {
203+ 'thread ' => new ThreadKey ($ fid , $ postId , $ orderByFieldValue ),
204+ 'reply ' => new ReplyKey ($ fid , $ tid , $ postId , $ orderByFieldValue ),
205+ 'subReply ' => new SubReplyKey ($ fid , $ tid , $ pid , $ postId , $ orderByFieldValue )
206+ };
207+ })
208+ ]);
209+ Helper::abortAPIIf (40401 , $ postsKeyByTypePluralName ->every (static fn (Collection $ i ) => $ i ->isEmpty ()));
210+
211+ return [
212+ 'unionOfQueriesSQL ' => $ unionOfQueriesSQL ,
213+ 'postsKeyByTypePluralName ' => $ postsKeyByTypePluralName ,
214+ 'hasMorePages ' => $ hasMorePages ,
215+ 'queryPlan ' => $ queryPlan
216+ ];
217+ }
151218}
0 commit comments