slonik-utilities
Version:
Utilities for manipulating data in PostgreSQL database using Slonik.
205 lines (170 loc) • 4.59 kB
text/typescript
import {
difference,
uniq,
mapKeys,
snakeCase,
} from 'lodash';
import {
sql,
type CommonQueryMethods,
type ValueExpression,
} from 'slonik';
import {
Logger,
} from '../Logger';
type NamedValueBindingsType = {
readonly [key: string]: ValueExpression,
};
type UpsertConfigurationType = {
readonly identifierName: string,
};
const log = Logger.child({
namespace: 'upsert',
});
const normalizeNamedValueBindingName = (name: string): string => {
return snakeCase(name);
};
const defaultConfiguration = {
identifierName: 'id',
};
export const upsert = async (
connection: CommonQueryMethods,
tableName: string,
namedValueBindings: NamedValueBindingsType,
inputUniqueConstraintColumnNames: readonly string[] | null = null,
inputConfiguration: UpsertConfigurationType | null = null,
) => {
const configuration = {
...defaultConfiguration,
...inputConfiguration,
};
const namedValueBindingNamesWithUndefinedValues: string[] = [];
const boundValues: Array<boolean | number | string | null> = [];
const normalizedNamedValueBindings = mapKeys(
namedValueBindings,
(value, key) => {
if (value === undefined) {
namedValueBindingNamesWithUndefinedValues.push(key);
}
boundValues.push(value);
return normalizeNamedValueBindingName(key);
},
);
if (namedValueBindingNamesWithUndefinedValues.length > 0) {
log.warn(
{
namedValueBindingNamesWithUndefinedValues,
},
'named value bindings with undefined values',
);
throw new Error('Named value binding values must be defined.');
}
const columnNames = Object.keys(normalizedNamedValueBindings);
const uniqueConstraintColumnNames =
inputUniqueConstraintColumnNames ?? columnNames;
if (difference(uniqueConstraintColumnNames, columnNames).length > 0) {
throw new Error(
'Unique constraint column names must not contain column names not present in named value bindings.',
);
}
const updateColumnNames = difference(
columnNames,
uniqueConstraintColumnNames,
);
if (columnNames.length === 0) {
throw new Error('Named value bindings object must have properties.');
}
const columnIdentifiers = sql.join(
columnNames.map((columnName) => {
return sql.identifier([
columnName,
]);
}),
sql.fragment`, `,
);
const conflictColumnIdentifiers = sql.join(
uniqueConstraintColumnNames.map((uniqueConstraintColumnName) => {
return sql.identifier([
uniqueConstraintColumnName,
]);
}),
sql.fragment`, `,
);
let updateClause;
if (updateColumnNames.length) {
updateClause = sql.join(
updateColumnNames.map((updateColumnName) => {
return sql.fragment`${sql.identifier([
updateColumnName,
])} = ${sql.identifier([
'excluded',
updateColumnName,
])}`;
}),
sql.fragment`, `,
);
}
const targetColumnNames = uniq([
...uniqueConstraintColumnNames,
...updateColumnNames,
]);
const whereClause = sql.join(
targetColumnNames.map((targetColumnName) => {
const value =
normalizedNamedValueBindings[
normalizeNamedValueBindingName(targetColumnName)
];
if (value === null) {
return sql.fragment`${sql.identifier([
targetColumnName,
])} IS NULL`;
}
return sql.fragment`${sql.identifier([
targetColumnName,
])} = ${value}`;
}),
sql.fragment` AND `,
);
const selectQuery = sql.unsafe`
SELECT ${sql.identifier([
configuration.identifierName,
])}
FROM ${sql.identifier([
tableName,
])}
WHERE
${whereClause}
`;
let maybeId;
maybeId = await connection.maybeOneFirst(selectQuery);
if (maybeId) {
return maybeId;
}
if (updateClause) {
return await connection.oneFirst(sql.unsafe`
INSERT INTO ${sql.identifier([
tableName,
])} (${columnIdentifiers})
VALUES (${sql.join(boundValues, sql.fragment`, `)})
ON CONFLICT (${conflictColumnIdentifiers})
DO UPDATE
SET
${updateClause}
RETURNING ${sql.identifier([
configuration.identifierName,
])}
`);
}
maybeId = await connection.maybeOneFirst(sql.unsafe`
INSERT INTO ${sql.identifier([
tableName,
])} (${columnIdentifiers})
VALUES (${sql.join(boundValues, sql.fragment`, `)})
ON CONFLICT (${conflictColumnIdentifiers})
DO NOTHING
`);
if (maybeId) {
return maybeId;
}
return await connection.oneFirst(selectQuery);
};