Skip to content

perf: improve to-one relational filters #4235

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 12 commits into from
Sep 27, 2023

Conversation

Weakky
Copy link
Contributor

@Weakky Weakky commented Sep 13, 2023

Overview

fixes prisma/prisma#14688
fixes prisma/prisma#17879
fixes prisma/prisma#7894

  • Refactor the filter builder to use the visitor pattern & splitted everything into smaller modules
  • Use LEFT JOINs when traversing to-one relations. Note: when rendering joins for mutations, we always need at least one sub-select before we can render nested joins.
  • Even though it's not directly part of this PR, it also integrates the improvements from Remove unnecessary join in RelationFilter #3882, which will be part of the benchmarks down below.
  • Caveats: Joins are not deduplicated, even within a same filter. So we do not fix Optimisation: Combine where directives on related records prisma#15417

SQL differences

Consider this schema:

model User {
  id   Int     @id @default(autoincrement())
  name String?

  posts Post[]
}

model Post {
  id    Int     @id @default(autoincrement())
  title String?

  userId Int?
  user   User? @relation(fields: [userId], references: [id])

  comments Comment[]
}

model Comment {
  id   Int     @id @default(autoincrement())
  body String?

  postId Int?
  post   Post? @relation(fields: [postId], references: [id])
}

to-one -> to-one

{
  findManyComment(where: { post: { is: { author: { is: { name: "John" } } } } }) {
    id
  }
}

Before 👇

SELECT
  "Comment"."id"
FROM
  "Comment"
WHERE
  ("Comment"."id") IN (
    SELECT
      "t0"."id"
    FROM
      "Comment" AS "t0"
      INNER JOIN "Post" AS "j0" ON ("j0"."id") = ("t0"."postId")
    WHERE
      (
        ("j0"."id") IN (
          SELECT
            "t1"."id"
          FROM
            "Post" AS "t1"
            INNER JOIN "User" AS "j1" ON ("j1"."id") = ("t1"."userId")
          WHERE
            (
              "j1"."name" = $ 1
              AND "t1"."id" IS NOT NULL
            )
        )
        AND "t0"."id" IS NOT NULL
      )
  );

After 👇

SELECT
  "Comment"."id"
FROM
  "Comment"
  LEFT JOIN "Post" AS "j1" ON ("j1"."id") = ("Comment"."postId")
  LEFT JOIN "User" AS "j2" ON ("j2"."id") = ("j1"."userId")
WHERE
  (
    "j2"."name" = $ 1
    AND ("j2"."id" IS NOT NULL)
    AND ("j1"."id" IS NOT NULL)
  );

some -> to-one (inlined)

{
  findManyUser(where: { posts: { some: { author: { is: { name: "John" } } } } }) {
    id
  }
}

Before 👇

SELECT
  "User"."id"
FROM
  "User"
WHERE
  ("User"."id") IN (
    SELECT
      "t0"."id"
    FROM
      "User" AS "t0"
      INNER JOIN "Post" AS "j0" ON ("j0"."userId") = ("t0"."id")
    WHERE
      (
        ("j0"."id") IN (
          SELECT
            "t1"."id"
          FROM
            "Post" AS "t1"
            INNER JOIN "User" AS "j1" ON ("j1"."id") = ("t1"."userId")
          WHERE
            (
              "j1"."name" = $1
              AND "t1"."id" IS NOT NULL
            )
        )
        AND "t0"."id" IS NOT NULL
      )
  );

After 👇

SELECT
  "User"."id"
FROM
  "User"
WHERE
  ("User"."id") IN (
    SELECT
      "t1"."userId"
    FROM
      "Post" AS "t1"
      LEFT JOIN "User" AS "j2" ON ("j2"."id") = ("t1"."userId")
    WHERE
      (
        "j2"."name" = $ 1
        AND ("j2"."id" IS NOT NULL)
        AND "t1"."userId" IS NOT NULL
      )
  );

some -> every -> to-one (inlined)

{
  findManyUser(
    where: {
      posts: {
        some: { comments: { every: { post: { is: { title: "Hello" } } } } }
      }
    }
  ) {
    id
  }
}

Before 👇

SELECT
  "User"."id"
FROM
  "User"
WHERE
  ("User"."id") IN (
    SELECT
      "t0"."id"
    FROM
      "User" AS "t0"
      INNER JOIN "Post" AS "j0" ON ("j0"."userId") = ("t0"."id")
    WHERE
      (
        ("j0"."id") NOT IN (
          SELECT
            "t1"."id"
          FROM
            "Post" AS "t1"
            INNER JOIN "Comment" AS "j1" ON ("j1"."postId") = ("t1"."id")
          WHERE
            (
              (
                NOT ("j1"."id") IN (
                  SELECT
                    "t2"."id"
                  FROM
                    "Comment" AS "t2"
                    INNER JOIN "Post" AS "j2" ON ("j2"."id") = ("t2"."postId")
                  WHERE
                    (
                      "j2"."title" = $ 1
                      AND "t2"."id" IS NOT NULL
                    )
                )
              )
              AND "t1"."id" IS NOT NULL
            )
        )
        AND "t0"."id" IS NOT NULL
      )
  );

After 👇

SELECT
  "User"."id"
FROM
  "User"
WHERE
  ("User"."id") IN (
    SELECT
      "t1"."userId"
    FROM
      "Post" AS "t1"
    WHERE
      (
        ("t1"."id") NOT IN (
          SELECT
            "t2"."postId"
          FROM
            "Comment" AS "t2"
            LEFT JOIN "Post" AS "j3" ON ("j3"."id") = ("t2"."postId")
          WHERE
            (
              (
                NOT (
                  "j3"."title" = $1
                  AND ("j3"."id" IS NOT NULL)
                )
              )
              AND "t2"."postId" IS NOT NULL
            )
        )
        AND "t1"."userId" IS NOT NULL
      )
  );

Benchmarks

(source)

cpu: Apple M1 Max
runtime: node v18.17.1 (arm64-darwin)

benchmark      time (avg)             (min … max)       p75       p99      p995
------------------------------------------------- -----------------------------
• prisma.movie.findMany({ where: { reviews: { author: { ... } }, take: 100 }) (to-many -> to-one)
------------------------------------------------- -----------------------------
Prisma V4  572.87 ms/iter (524.49 ms … 647.22 ms) 585.46 ms 647.22 ms 647.22 ms
Prisma V5  314.03 ms/iter (283.22 ms … 381.27 ms) 323.18 ms 381.27 ms 381.27 ms

summary for prisma.movie.findMany({ where: { reviews: { author: { ... } }, take: 100 }) (to-many -> to-one)
  Prisma V5
   1.82x faster than Prisma V4

• prisma.movie.findMany({ where: { cast: { person: { ... } }, take: 100 }) (m2m -> to-one)
------------------------------------------------- -----------------------------
Prisma V4    6.75 ms/iter    (3.36 ms … 13.96 ms)   7.87 ms  13.96 ms  13.96 ms
Prisma V5    4.92 ms/iter    (2.88 ms … 12.05 ms)   5.75 ms   9.77 ms  12.05 ms

summary for prisma.movie.findMany({ where: { cast: { person: { ... } }, take: 100 }) (m2m -> to-one)
  Prisma V5
   1.37x faster than Prisma V4

• prisma.review.findMany({ where: { author: { ... } }, take: 100 }) (to-one)
------------------------------------------------- -----------------------------
Prisma V4     1.91 s/iter       (1.82 s … 2.05 s)    2.01 s    2.05 s    2.05 s
Prisma V5    5.03 ms/iter    (2.61 ms … 11.14 ms)   6.15 ms   9.43 ms  11.14 ms

summary for prisma.review.findMany({ where: { author: { ... } }, take: 100 }) (to-one)
  Prisma V5
   379.24x faster than Prisma V4

• prisma.actor.findMany({ where: { movies: { some: { reviews: { some: { author: { ... } } } } } }, take: 20 } (to-many -> to-many -> to-one)
------------------------------------------------- -----------------------------
Prisma V4  506.46 ms/iter (444.91 ms … 593.76 ms) 529.23 ms 593.76 ms 593.76 ms
Prisma V5   268.7 ms/iter (208.79 ms … 300.38 ms) 286.87 ms 300.38 ms 300.38 ms

summary for prisma.actor.findMany({ where: { movies: { some: { reviews: { some: { author: { ... } } } } } }, take: 20 } (to-many -> to-many -> to-one)
  Prisma V5
   1.88x faster than Prisma V4

@Weakky Weakky requested a review from a team as a code owner September 13, 2023 14:00
@Weakky Weakky force-pushed the perf/relational-filters-to-one-joins branch from cca604d to befe230 Compare September 13, 2023 14:03
@Weakky Weakky changed the title perf: improve relational filters perf: improve to-one relational filters Sep 13, 2023
@codspeed-hq
Copy link

codspeed-hq bot commented Sep 13, 2023

CodSpeed Performance Report

Merging #4235 will not alter performance

Comparing perf/relational-filters-to-one-joins (8a4bf6b) with main (818b9fc)

Summary

✅ 11 untouched benchmarks

@Weakky Weakky added this to the 5.4.0 milestone Sep 13, 2023
@@ -453,6 +453,14 @@ impl<'a> Select<'a> {
self
}

pub fn join<J>(mut self, join: J) -> Self
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add documentation

@@ -344,6 +344,20 @@ impl<'a> Table<'a> {

self
}

pub fn join<J>(self, join: J) -> Self
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add documentation

Comment on lines 370 to 373
insta::assert_snapshot!(
run_query!(&runner, r#"{ findManyAlbum(where: { Tracks: { some: { OR:[{ MediaType: {is: { Name: { equals: "MediaType1" }}}}, { Genre: { is: { Name: { equals: "Genre2" }}}}]}}}) { Title }}"#),
run_query!(&runner, r#"{ findManyAlbum(where: { Tracks: { some: { OR:[{ MediaType: {is: { Name: { equals: "MediaType1" }}}}, { Genre: { is: { Name: { equals: "Genre2" }}}}]}}}, orderBy: { Title: asc }) { Title }}"#),
@r###"{"data":{"findManyAlbum":[{"Title":"Album1"},{"Title":"Album3"},{"Title":"Album4"},{"Title":"Album5"}]}}"###
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering is needed because joins messes up the natural order

Comment on lines 509 to 512
insta::assert_snapshot!(
run_query!(&runner, r#"{ findManyGenre(where: { Tracks: { some: {} }}) { Name }}"#),
run_query!(&runner, r#"{ findManyGenre(where: { Tracks: { some: {} }}, orderBy: { Name: asc }) { Name }}"#),
@r###"{"data":{"findManyGenre":[{"Name":"Genre1"},{"Name":"Genre2"},{"Name":"Genre3"}]}}"###
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering is needed because joins messes up the natural order

@@ -31,8 +31,9 @@ pub(crate) async fn update_one_with_selection(
return get_single_record(conn, model, &filter, &selected_fields, &[], ctx).await;
}

let update = build_update_and_set_query(model, args, Some(&selected_fields), ctx)
.so_that(build_update_one_filter(record_filter).aliased_condition_from(None, false, ctx));
let cond = FilterBuilder::without_top_level_joins().visit_filter(build_update_one_filter(record_filter), ctx);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like for all mutations, we don't render filters with top-level joins because we can't.

Copy link
Contributor Author

@Weakky Weakky Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all copy & pasted stuff that comes with the filter refactoring. So no changes in this file.

Comment on lines +236 to +258
field: &RelationFieldRef,
alias: &str,
parent_alias: Option<&str>,
ctx: &Context<'_>,
) -> AliasedJoin {
let (left_fields, right_fields) = if rf.is_inlined_on_enclosing_model() {
(rf.scalar_fields(), rf.referenced_fields())
} else {
(
rf.related_field().referenced_fields(),
rf.related_field().scalar_fields(),
)
};

let right_table_alias = format!("{}_{}", join_prefix, rf.related_model().name());

let related_model = rf.related_model();
let pairs = left_fields.into_iter().zip(right_fields);

let on_conditions: Vec<Expression> = pairs
.map(|(a, b)| {
let a_col = match previous_join {
Some(prev_join) => Column::from((prev_join.alias.to_owned(), a.db_name().to_owned())),
None => a.as_column(ctx),
};
let join_columns: Vec<Column> = field
.join_columns(ctx)
.map(|c| c.opt_table(parent_alias.map(ToOwned::to_owned)))
.collect();

let b_col = Column::from((right_table_alias.clone(), b.db_name().to_owned()));
let related_table = field.related_model().as_table(ctx);
let related_join_columns: Vec<_> = ModelProjection::from(field.related_field().linking_fields())
.as_columns(ctx)
.map(|col| col.table(alias.to_owned()))
.collect();

a_col.equals(b_col).into()
})
.collect::<Vec<_>>();
let join = related_table
.alias(alias.to_owned())
.on(Row::from(related_join_columns).equals(Row::from(join_columns)));

AliasedJoin {
alias: right_table_alias.to_owned(),
data: related_model
.as_table(ctx)
.alias(right_table_alias)
.on(ConditionTree::And(on_conditions)),
alias: alias.to_owned(),
data: Join::Left(join),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the refactorings of this function is just a nicest way to express the same thing.

match alias {
Some(alias) => self.table(alias.to_string(None)),
None => self,
fn visit_relation_filter_select(&mut self, filter: RelationFilter, ctx: &Context<'_>) -> Select<'static> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Untouched function except to accommodate for the new shared state and apis that come with it

};
(conditions.not(), Some(output_joins))
}
RelationCondition::ToOneRelatedRecord if self.should_render_join() && !filter.field.is_list() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{ to_one: { ... } } now uses a LEFT JOIN.

Comment on lines 426 to 458
// If the relation is not inlined and we can use joins, then we join the relation and check whether the related linking fields are null.
//
// ```sql
// SELECT "Parent"."id" FROM "Parent"
// LEFT JOIN "Child" AS "j1" ON ("j1"."parentId" = "Parent"."id")
// WHERE "j1"."parentId" IS NULL OFFSET;
// ```
if self.should_render_join() {
let alias = self.next_alias(AliasMode::Join);

let conditions: Vec<_> = ModelProjection::from(filter.field.related_field().linking_fields())
.as_columns(ctx)
.map(|col| col.aliased_col(Some(alias.flip(AliasMode::Join)), ctx))
.map(|c| c.aliased_col(Some(alias), ctx))
.map(|c| c.is_null())
.map(Expression::from)
.collect();

let nested_conditions = self
.nested_filter
.aliased_condition_from(Some(alias.flip(AliasMode::Join)), false, ctx)
.invert_if(condition.invert_of_subselect());

let conditions = selected_identifier
.clone()
.into_iter()
.fold(nested_conditions, |acc, column| acc.and(column.is_not_null()));

let join = related_table
.alias(alias.to_string(Some(AliasMode::Join)))
.on(Row::from(related_join_columns).equals(Row::from(join_columns)));
let join = compute_one2m_join(
&filter.field,
alias.to_string(None).as_str(),
parent_alias_string.as_deref(),
ctx,
);
Copy link
Contributor Author

@Weakky Weakky Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is the most important change of this function. When traversing a { to_one: null }, we're now using a join if we can.

let sub_select = Select::from_table(relation_table)
.columns(columns)
.and_where(columns_not_null);
fn visit_scalar_list_filter(&mut self, filter: ScalarListFilter, ctx: &Context<'_>) -> ConditionTree<'static> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Untouched function

AggregationFilter::Sum(filter) => aggregate_conditions(*filter, alias, reverse, |x| sum(x).into(), ctx),
AggregationFilter::Min(filter) => aggregate_conditions(*filter, alias, reverse, |x| min(x).into(), ctx),
AggregationFilter::Max(filter) => aggregate_conditions(*filter, alias, reverse, |x| max(x).into(), ctx),
fn scalar_filter_aliased_cond(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Untouched function

Comment on lines 7 to 12
pub(crate) struct AliasedJoin {
// Actual join data to be passed to quaint
pub(crate) data: JoinData<'static>,
pub(crate) data: Join<'static>,
// Alias used for the join. eg: LEFT JOIN ... AS <alias>
pub(crate) alias: String,
}
Copy link
Contributor Author

@Weakky Weakky Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An AliasedJoin now also holds the type of join. Makes it easier to fold on a list of joins.

Comment on lines +68 to +71
let (filter, filter_joins) = self
.filter
.map(|f| f.aliased_condition_from(None, false, ctx))
.unwrap_or(ConditionTree::NoCondition);
.map(|f| FilterBuilder::with_top_level_joins().visit_filter(f, ctx))
.unwrap_or((ConditionTree::NoCondition, None));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we gather the filter and the associated new joins with it.

Comment on lines +91 to +97
let joined_table = if let Some(filter_joins) = filter_joins {
filter_joins
.into_iter()
.fold(joined_table, |acc, join| acc.join(join.data))
} else {
joined_table
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These joins are then added here to the top-level part of the SELECT AST.

Copy link
Contributor

@miguelff miguelff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback provided on a call together. This looks good from my perspective.

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
@Weakky Weakky force-pushed the perf/relational-filters-to-one-joins branch from 6af97ef to 53e8b3b Compare September 22, 2023 12:17
@Weakky Weakky merged commit 6dc5bad into main Sep 27, 2023
@Weakky Weakky deleted the perf/relational-filters-to-one-joins branch September 27, 2023 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants