drizzle-team / drizzle-orm

Headless TypeScript ORM with a head. Runs on Node, Bun and Deno. Lives on the Edge and yes, it's a JavaScript ORM too 😅
https://orm.drizzle.team
Apache License 2.0
24.47k stars 642 forks source link

[FEATURE]: onConflictDoUpdate() set many #1728

Open janvorwerk opened 10 months ago

janvorwerk commented 10 months ago

Describe what you want

This is a follow-up of a discussion on discord

The idea is that, when inserting an array of object values, it's a bit complex to ask for an update for value which cause a conflict. I came up with something like the following


const values: CrmCompanies[] = ... ;

await db
    .insert(crmCompanies)
    .values(values)
    .onConflictDoUpdate({
      target: crmCompanies.id,
      set: Object.assign(
        {},
        ...Object.keys(values[0])
          .filter((k) => k !== "id")
          .map((k) => ({ [k]: sql`excluded.${k}` })),
      ) as Partial<CrmCompanies>,
    });

As you can see, the syntax is not very easy to come up with, and we lose type information. Ideally, I would rather write something such as (not necessarily that exact syntax of course):

const values: CrmCompanies[] = ... ;

await db
    .insert(crmCompanies)
    .values(values)
    .onConflictDoUpdate({
      target: crmCompanies.id,
      set: values,
    });
janvorwerk commented 10 months ago

I realized that I oversimplified my code above... it no longer works. Given a utility function such as:

function keysFromObject<T extends object>(object: T): (keyof T)[] {
  return Object.keys(object) as (keyof T)[];
}

this code seems to work:

await skDrizzleDb
  .insert(crmCompanies)
  .values(values)
  .onConflictDoUpdate({
    target: crmCompanies.id,
    set: Object.assign(
      {},
      ...keysFromObject(values[0])
        .filter((k) => k !== "id")
        .map((k) => ({ [k]: sql.raw(`excluded.${crmCompanies[k].name}`) })),
    ) as Partial<CrmCompany>,
  });
lxia1220 commented 10 months ago

how about like this?

const values: CrmCompanies[] = ... ;

await db
    .insert(crmCompanies)
    .values(values)
    .onConflictDoUpdate({
      target: crmCompanies.id,
      set: ({excluded}) => ({
        value: excluded.value
      }),
    });
Angelelz commented 10 months ago

I haven't looked into this but doesn't this work?

await db
    .insert(crmCompanies)
    .values(values)
    .onConflictDoUpdate({
      target: crmCompanies.id,
      set: {
         value: sql`excluded.value`,
         anotherValue: sql`excluded.anotherValue`,
         ... etc
      },
    });

Or listing all the columns is what you're trying to avoid?

janvorwerk commented 10 months ago

Or listing all the columns is what you're trying to avoid?

Yes, listing all the columns is what I refuse to do because the day I add a new column is when the sh** hits the fan: nothing will complain (no type issue, ...) but one data will be let on the side.

janvorwerk commented 10 months ago
    set: ({excluded}) => ({
        value: excluded.value
      }),

@lxia1220, I don't think that set can be given such a function... can it?

richard-edwards commented 9 months ago

@Angelelz thanks .. that works great .. should be in the docs

    await db.transaction(async (db) => {
      await db.insert(player).values(results)
        .onConflictDoUpdate({ target: player.id, set: { ranking: sql`excluded.ranking` } })
    })
Angelelz commented 9 months ago

This is more like a general SQL knowledge, more that it's drizzle. I believe the docs will have some improvements anyway.

AidanLaycock commented 9 months ago

Hey, is this actually feasible to capture the conflicting row so that I can then set it as something? As I don't think the thread above actually resolves the initial question?

Angelelz commented 9 months ago

Hey, is this actually feasible to capture the conflicting row so that I can then set it as something? As I don't think the thread above actually resolves the initial question?

The feature request is about a convenience more than an actual request. This currently possible, you just need to list all the fields.

janvorwerk commented 8 months ago

The feature request is about a convenience more than an actual request. This currently possible, you just need to list all the fields.

True. Actually, I had several cases where I did not want to update all the fields in case of conflicts... I realize that it can be more subtle than I initially thought.

FWIW, I wrote this little syntaxic sugar that makes it (a bit) easier to read the code:

export function conflictUpdateSet<TTable extends PgTable>(
  table: TTable,
  columns: (keyof TTable["_"]["columns"] & keyof TTable)[],
): PgUpdateSetSource<TTable> {
  return Object.assign(
    {},
    ...columns.map((k) => ({ [k]: sql.raw(`excluded.${(table[k] as Column).name}`) })),
  ) as PgUpdateSetSource<TTable>;
}

Here is how I use it:

     tx
      .insert(skSession)
      .values(sessions)
      .onConflictDoUpdate({
        target: [skSession.session],
        set: conflictUpdateSet(skSession, [
          "startTimestamp",
          "stats",
          // ... all colums to update here
        ]),
      });

Not sure if it's the cleanest solution... Drizzle internals are a bit obscure to me.

capaj commented 8 months ago

@drizzle-team I wish this conflictUpdateSet was included in postgres/sqlite packages to make it easier to do upserts. Upserts should be encouraged. If you will not add the conflictUpdateSet please at least document this in

https://orm.drizzle.team/docs/insert#on-conflict-do-nothing

actually I wish there was a section in the docs

https://orm.drizzle.team/docs/insert#on-conflict-do-update which is IMHO a more common usecase than on-conflict-do-nothing, certainly when making APIs

also here is the helper for sqlite: https://gist.github.com/capaj/270a4acaf649cc1242fc09e993c95f50

dBianchii commented 8 months ago

+1 to this. Would be great if drizzle could have a way to facilitate this

dBianchii commented 8 months ago

This took me forever. I am using Mysql. I had to do this:

function allSetValues(values: Record<string, unknown>[]) {
  return Object.assign(
    {},
    ...Object.keys(values[0]!).map((k) => {
      return { [k]: sql.raw(`values(${k})`) }; //Needs to be raw because otherwise it will have 3 string chunks!
    }),
  ) as Record<string, unknown>;
}

//Usage:
await db
      .insert(schema.devPartners)
      .values(devPartners)
      .onDuplicateKeyUpdate({
        set: allSetValues(devPartners),
      });

This was overly complicated for me to just have an upsert. Maybe I am a tad bit too spoiled by prisma

tjapa commented 8 months ago

I made this function to generate the set with all columns except the ones with default values and merge with the current row values.

import { sql } from 'drizzle-orm'
import { PgUpdateSetSource, PgTable } from 'drizzle-orm/pg-core'
import { getTableColumns } from 'drizzle-orm'
import { getTableConfig } from 'drizzle-orm/pg-core'

export function conflictUpdateSetAllColumns<TTable extends PgTable>(
  table: TTable,
): PgUpdateSetSource<TTable> {
  const columns = getTableColumns(table)
  const { name: tableName } = getTableConfig(table)
  const conflictUpdateSet = Object.entries(columns).reduce(
    (acc, [columnName, columnInfo]) => {
      if (!columnInfo.default) {
        // @ts-ignore
        acc[columnName] = sql.raw(
          `COALESCE(excluded.${columnInfo.name}, ${tableName}.${columnInfo.name})`,
        )
      }
      return acc
    },
    {},
  ) as PgUpdateSetSource<TTable>
  return conflictUpdateSet
}
capaj commented 8 months ago

nice one @tjapa, will update my gist too in case anyone would land there looking for it from search engine

richard-edwards commented 8 months ago

Love it @tjapa ! :1st_place_medal:

Does anyone have a library started that has all these neat little extras that we all need?

Here's a quick code snippet on how to apply it just in case anyone comes across this:

export async function updatePoolTeamPlayers(input: PoolTeamPlayer[]): Promise<void> {
  await db.insert(pool_team_player).values(input)
    .onConflictDoUpdate({ target: pool_team_player.id, set: conflictUpdateSetAllColumns(pool_team_player) })
}
sp88011 commented 6 months ago

I'm using camelCase to name my columns, for example:

const user = pgTable("user", {
 firstName: varchar("firstName")
//...

seems like these helper functions seem to ignore (?) this so I get errors like:

column.firstname does not exist

Is there some workaround or will I have to rename all my columns...

ItzDerock commented 5 months ago

Sharing my snippet for sqlite -- sets all the rows except for the ones defined in the second argument:

export function conflictUpdateAllExcept<
  T extends SQLiteTable,
  E extends (keyof T["$inferInsert"])[],
>(table: T, except: E) {
  const columns = getTableColumns(table);
  const updateColumns = Object.entries(columns).filter(
    ([col]) => !except.includes(col as keyof typeof table.$inferInsert),
  );

  return updateColumns.reduce(
    (acc, [colName, table]) => ({
      ...acc,
      [colName]: sql.raw(`excluded.${table.name}`),
    }),
    {},
  ) as Omit<Record<keyof typeof table.$inferInsert, SQL>, E[number]>;
}

// usage:
await db
  .insert(column) // column: SQLiteTableWithColumns<...>
  .values(values) // values: (typeof column.$inferInsert)[]
  .onConflictDoUpdate({
    set: conflictUpdateAllExcept(column, ["id"]),
    target: column.id,
  });

Should be fully type-safe too, you'll get intellisense results for the except array as well as an error if you put a column that doesn't exist.

austinm911 commented 5 months ago

@tjapa @capaj I thought I had run into this before when trying out using postgres schemas and trying to drop all tables across different schemas, but getTableColumns doesn't return the prefixed table name, correct? So if you have tables schema1.table_name then getTableColumns will just return table_name. So I wasn't able to figure out at the time how to use sql.raw and inject the schema name in there. I'm guessing this might be an issue doing upserts if they are on different schemas?

madc commented 5 months ago

Sharing my snippet for sqlite -- sets all the rows except for the ones defined in the second argument:

export function conflictUpdateAllExcept<
  T extends SQLiteTable,
  E extends (keyof T["$inferInsert"])[],
>(table: T, except: E) {
  const columns = getTableColumns(table);
  const updateColumns = Object.entries(columns).filter(
    ([col]) => !except.includes(col as keyof typeof table.$inferInsert),
  );

  return updateColumns.reduce(
    (acc, [colName, table]) => ({
      ...acc,
      [colName]: sql.raw(`excluded.${table.name}`),
    }),
    {},
  ) as Omit<Record<keyof typeof table.$inferInsert, SQL>, E[number]>;
}

// usage:
await db
  .insert(column) // column: SQLiteTableWithColumns<...>
  .values(values) // values: (typeof column.$inferInsert)[]
  .onConflictDoUpdate({
    set: conflictUpdateAllExcept(column, ["id"]),
    target: column.id,
  });

Should be fully type-safe too, you'll get intellisense results for the except array as well as an error if you put a column that doesn't exist.

This works like a charm, thanks for sharing!

tjapa commented 5 months ago

@tjapa @capaj I thought I had run into this before when trying out using postgres schemas and trying to drop all tables across different schemas, but getTableColumns doesn't return the prefixed table name, correct? So if you have tables schema1.table_name then getTableColumns will just return table_name. So I wasn't able to figure out at the time how to use sql.raw and inject the schema name in there. I'm guessing this might be an issue doing upserts if they are on different schemas?

Maybe you can get the schema using the getTableConfig function. I saw the function returns object with a field called schema.

WarlockJa commented 5 months ago

Sharing my snippet for sqlite -- sets all the rows except for the ones defined in the second argument:

export function conflictUpdateAllExcept<
  T extends SQLiteTable,
  E extends (keyof T["$inferInsert"])[],
>(table: T, except: E) {
  const columns = getTableColumns(table);
  const updateColumns = Object.entries(columns).filter(
    ([col]) => !except.includes(col as keyof typeof table.$inferInsert),
  );

  return updateColumns.reduce(
    (acc, [colName, table]) => ({
      ...acc,
      [colName]: sql.raw(`excluded.${table.name}`),
    }),
    {},
  ) as Omit<Record<keyof typeof table.$inferInsert, SQL>, E[number]>;
}

// usage:
await db
  .insert(column) // column: SQLiteTableWithColumns<...>
  .values(values) // values: (typeof column.$inferInsert)[]
  .onConflictDoUpdate({
    set: conflictUpdateAllExcept(column, ["id"]),
    target: column.id,
  });

Should be fully type-safe too, you'll get intellisense results for the except array as well as an error if you put a column that doesn't exist.

@ItzDerock this is awesome! Thank you for sharing this.

niklasravnsborg commented 3 months ago
import { getTableColumns, SQL, sql, Table } from 'drizzle-orm'

export function conflictUpdateAllExcept<
  T extends Table,
  E extends (keyof T['$inferInsert'])[],
>(table: T, except: E) {
  const columns = getTableColumns(table)
  const updateColumns = Object.entries(columns).filter(
    ([col]) => !except.includes(col as keyof typeof table.$inferInsert),
  )

  return updateColumns.reduce(
    (acc, [colName, table]) => ({
      ...acc,
      [colName]: sql.raw(`excluded.${table.name}`),
    }),
    {},
  ) as Omit<Record<keyof typeof table.$inferInsert, SQL>, E[number]>
}

@ItzDerock Awesome, thank you! This also works for Postgres as far as I tested. I just replaced SQLiteTable with the generic Table.

adamsullovey commented 2 months ago

I'm using camelCase to name my columns, for example:

const user = pgTable("user", {
 firstName: varchar("firstName")
//...

seems like these helper functions seem to ignore (?) this so I get errors like:

column.firstname does not exist

Is there some workaround or will I have to rename all my columns...

@sp88011 I had a similar problem and had to use some more quotes to resolve it. If I use ItzDerock's example from https://github.com/drizzle-team/drizzle-orm/issues/1728#issuecomment-2148635569

This line

      [colName]: sql.raw(`excluded.${table.name}`),

needs to be

      [colName]: sql.raw(`excluded."${table.name}"`),

If I understand it right, PostgreSQL will convert the identifiers like column names to lowercase unless double quoted.

bitofbreeze commented 1 week ago

These helper functions have been really useful, but they break with the new snake_case feature. table.name is still the titlecase version and you get a no such column: excluded.titleCase error. Any idea how to add compatibility for that?

lxia1220 commented 1 week ago

These helper functions have been really useful, but they break with the new snake_case feature. table.name is still the titlecase version and you get a no such column: excluded.titleCase error. Any idea how to add compatibility for that?

you can do with internal things

(db.dialect.casing as CasingCache).getColumnCasing(schema.table.column)