Files
nowdatabase/backend/src/services/write/writeUtils.ts
T

371 lines
14 KiB
TypeScript

import { PoolConnection } from 'mariadb'
import { EditDataType, Reference, ReferenceDetailsType } from '../../../../frontend/src/backendTypes'
import { COORDINATOR, NOW_DB_NAME, RUNNING_ENV } from '../../utils/config'
import { nowDb } from '../../utils/db'
import { logger } from '../../utils/logger'
import { createUpdateEntry, writeLogRows } from './updateAndLog'
import { WriteContext } from './write'
const tableNameToPrefix = {
now_loc: 'lau',
com_species: 'sau',
now_time_unit: 'tau',
now_tu_bound: 'bau',
} as Record<PrimaryTables, 'sau' | 'tau' | 'lau' | 'bau'>
/* Updates to certain tables have to be logged in a different table
than the one the user edited. Sometimes several tables. For example,
user editing locality and changing now_ls will require update entry
to both now_loc and com_species. If this table doesn't have entry for a
table, assume it goes to the "main" update entry e.g. if user edited locality, now_loc. */
const tableToUpdateTargets = {
now_ls: ['now_loc', 'com_species'],
} as Record<AllowedTables, PrimaryTables[] | undefined>
type PrimaryTables = 'now_loc' | 'com_species' | 'now_time_unit' | 'now_tu_bound'
export type ActionType = 'delete' | 'update' | 'add'
export type LogRow = Item & {
pkData: string
suid?: number
luid?: number
buid?: number
tuid?: number
type: ActionType
}
type UpdateEntry = {
table: PrimaryTables
logRows: LogRow[]
type: ActionType
id: string | number // Id of the item that was changed
entryId?: number // Id of the created update entry
}
export const logAllUpdates = async (
writeContext: WriteContext,
tableName: PrimaryTables,
userInitials: string,
comment: string,
id: string | number,
references: Reference[]
) => {
const updateEntries: UpdateEntry[] = []
const { writeList } = writeContext
const getIdColumn = (targetTable: AllowedTables) => {
if (targetTable === 'com_species') return 'species_id'
if (targetTable === 'now_mus') return 'museum'
if (targetTable === 'now_ls') return tableName === 'now_loc' ? 'species_id' : 'lid'
if (targetTable === 'now_loc') return 'lid'
if (targetTable === 'now_ss') return 'sed_struct'
if (targetTable === 'com_taxa_synonym') return 'synonym_id'
if (targetTable === 'now_coll_meth') return 'coll_meth'
throw new Error(`No id column found for ${targetTable}`)
}
const getSecondaryPkData = (table: AllowedTables, items: Item[]) => {
if (table === tableName) return ''
const secondaryId = items.find(item => item.column === getIdColumn(table))?.value
if (!secondaryId) throw new Error(`No id value found for ${table}`)
return `${secondaryId.toString().length}.${secondaryId};`
}
const rootUpdateEntry: UpdateEntry = {
type: writeContext.type,
table: tableName,
id,
logRows: writeList.flatMap(writeItem =>
writeItem.items.map(item => ({
...item,
type: writeItem.type,
pkData: `${id.toString().length}.${id};${getSecondaryPkData(writeItem.table, writeItem.items)}`,
}))
),
}
updateEntries.push(rootUpdateEntry)
// Create "secondary" update-entries. This map holds by the primary table and id, e.g. secondaryUpdateEntries['species_id'][10001]
const secondaryUpdateEntries = {} as Record<PrimaryTables, Record<string | number, UpdateEntry>>
for (const writeListItem of writeList) {
const targetTables = tableToUpdateTargets[writeListItem.table] ?? [tableName]
for (const targetTable of targetTables) {
if (targetTable === tableName) continue
// find id of current entry. it will be the other id than the one of this table, e.g.
// if we are editing now_loc and this entry is triggered by now_ls (entry for com_species),
// the id will be "species_id", not "lid".
const idColumn = getIdColumn(targetTable)
const secondaryId = writeListItem.items.find(item => item.column === idColumn)?.value
if (!secondaryId)
throw new Error(`Error when creating update entries: id not found for targetTable: ${targetTable}`)
if (!secondaryUpdateEntries[targetTable]) secondaryUpdateEntries[targetTable] = {}
const pkData = `${id.toString().length}.${id};${secondaryId.toString().length}.${secondaryId};`
secondaryUpdateEntries[targetTable][secondaryId] = {
id: secondaryId,
table: targetTable,
type: writeListItem.type,
logRows: writeListItem.items.map(item => ({ ...item, pkData, type: writeListItem.type })),
}
}
}
for (const tableUpdates of Object.values(secondaryUpdateEntries)) {
for (const singleUpdateOfTable of Object.values(tableUpdates)) {
updateEntries.push(singleUpdateOfTable)
}
}
// Write the update-entries: This inserts the id's that are created into the objects.
await writeUpdateEntries(updateEntries, writeContext.connection, userInitials, comment)
await writeReferences(updateEntries, writeContext.connection, references)
// The write has value and oldValue swapped for 'delete' type logRows due to reasons.
// It is simpler to fix it here than changing how write works, because values are used to find id's above.
const fixDeleteRows = (logRow: LogRow) => {
if (logRow.type !== 'delete') return logRow
return { ...logRow, oldValue: logRow.value, value: null }
}
const logRows = mergeLogRows(updateEntries).map(logRow => fixDeleteRows(logRow))
await writeLogRows(writeContext.connection, logRows, userInitials)
}
const getUpdateIdField = (tableName: PrimaryTables) => `${tableName[4]}uid` as 'buid' | 'luid' | 'suid' | 'tuid'
const writeReferences = async (updateEntries: UpdateEntry[], connection: PoolConnection, references: Reference[]) => {
const referenceIds = references.map(ref => ref.rid)
for (const updateEntry of updateEntries) {
const id = updateEntry.entryId
const idField = getUpdateIdField(updateEntry.table)
const referenceJoinTable = `now_${idField[0]}r`
for (const referenceId of referenceIds) {
await connection.query(`INSERT INTO ${NOW_DB_NAME}.${referenceJoinTable} (${idField}, rid) VALUES (?, ?)`, [
id,
referenceId,
])
}
}
}
const mergeLogRows = (updateEntries: UpdateEntry[]) => {
const getLogRowKey = (logRow: LogRow) => {
return `${logRow.column}-${logRow.value}-${logRow.oldValue}-${logRow.table}-${logRow.pkData}`
}
const logRowMap: Record<string, LogRow> = {}
// Iterate through updateEntries. Collect logrows into map. LogRows with identical data may be in multiple
// updateEntries, such as now_ls rows in locality and species. These should be merged so that only one is left,
// but with both id's (luid and suid) found in the updateEntries.
for (const updateEntry of updateEntries) {
const idFieldName = getUpdateIdField(updateEntry.table)
for (const logRow of updateEntry.logRows) {
const key = getLogRowKey(logRow)
logRowMap[key] = { ...(logRowMap[key] ? logRowMap[key] : logRow), [idFieldName]: updateEntry.entryId }
}
}
return Object.values(logRowMap)
}
const writeUpdateEntries = async (
updateEntries: UpdateEntry[],
connection: PoolConnection,
authorizer: string,
comment: string
) => {
for (const updateEntry of updateEntries) {
const prefix = tableNameToPrefix[updateEntry.table]
const createdId = await createUpdateEntry(connection, prefix, COORDINATOR, authorizer, comment, updateEntry.id)
updateEntry.entryId = createdId
}
return updateEntries
}
// Some tables are not logged at all.
// TODO dont even use write() for these, make their own services.
export const tablesToNotLog = ['com_mlist']
export const getFieldTypes = <T extends CustomObject>(obj: T, allFields: string[]) => {
const basicFields = allFields.filter(
f => typeof obj[f as keyof object] !== 'object' || obj[f as keyof object] === null
)
const arrayFields = allFields.filter(f => Array.isArray(obj[f as keyof object])) as AllowedTables[]
const objectFields: AllowedTables[] = allFields.filter(
f => typeof obj[f] === 'object' && obj[f] !== null && !Array.isArray(obj[f])
) as AllowedTables[]
return { basicFields, arrayFields, objectFields }
}
export const getOldData = async <T extends CustomObject>(obj: T, tableName: AllowedTables) => {
// Create where-object with relevant ids to find
const where = ids[tableName].reduce<object>((acc: object, cur: keyof T) => {
if (obj[cur] !== undefined) return { ...acc, [cur]: obj[cur] as unknown }
return acc
}, {} as object) as unknown as object
// This creates the whereObject for tables whose primary id is composed of multiple ids
let whereObject: unknown = { [ids[tableName].join('_')]: where }
// Or if there is just one, then simply use that one
if (ids[tableName].length === 1) whereObject = { [ids[tableName][0]]: obj[ids[tableName][0]] }
debugLog(`Ids: ${ids[tableName].join(', ')}, where: ${printJSON(where)}`, true)
// Fetch the actual object
const oldObj: object | null =
ids[tableName] && obj[ids[tableName][0]] && Object.keys(where).length === ids[tableName].length
? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
((await (nowDb[tableName] as any).findUnique({
where: whereObject,
})) as object)
: null
debugLog(
`Old object: ${!!oldObj} ${Object.keys(whereObject as Record<string, string>).length} ${ids[tableName].length}`,
true
)
return oldObj
}
/*
Returns object where only the "original" fields remain.
*/
export const filterFields = <T extends object>(
editedObject: EditDataType<T>,
wantedFields: Record<string, unknown>
) => {
const fields = Object.keys(wantedFields)
const filteredFields = Object.entries(editedObject).filter(([field]) => fields.includes(field))
const filteredObject = filteredFields.reduce<Record<string, unknown>>((obj, cur) => {
obj[cur[0]] = cur[1]
return obj
}, {}) as Partial<T>
return { filteredFields, filteredObject }
}
export const replaceKey =
(f: (from: string) => string) =>
(o: object): object =>
Array.isArray(o)
? o.map(replaceKey(f))
: Object(o) === o
? Object.fromEntries(Object.entries(o).map(([k, v]: [string, object]) => [f(k), replaceKey(f)(v)]))
: o
export const revertFieldNames = (obj: object) => {
const fieldsToReplace = { species: 'now_ls' }
let newObj = obj
for (const [oldName, newName] of Object.entries(fieldsToReplace)) {
newObj = replaceKey(key => (key === oldName ? newName : key))(newObj)
}
return newObj
}
export const isNumeric = (value: string) => /^-?\d+$/.test(value)
// JSON.stringify doesn't preserve "key: undefined" entries, this prints those as a string instead.
// Also turns bigint to number because bigint cant be serialized
export const printJSON = (item: object | string | number) =>
JSON.stringify(
item,
(_k: string | number, v: unknown) => {
if (typeof v === 'bigint') return Number(v)
return v === undefined ? 'undefined' : v
},
2
)
export const isEmptyValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined) return true
if (typeof value === 'string' && value === '') return true
return false
}
// If doing hard debugging of write in local dev, set this to 2
const debugLevel = RUNNING_ENV === 'prod' ? 0 : 1
export const debugLog = (msg: string, onlyVerbose?: boolean) => {
if (debugLevel === 0) return
if (debugLevel === 1 && onlyVerbose) return
logger.info(msg)
}
export type CustomObject = Record<string, string | number | object | CustomObject[] | null | boolean>
export type WriteFunction = <T extends CustomObject>(
data: T,
tableName: PrimaryTables,
username: string,
comment: string,
type: ActionType,
references: Reference[]
) =>
| Promise<string | number>
| ((data: EditDataType<ReferenceDetailsType>, tableName: 'ref_ref') => Promise<string | number>)
export type AllowedTables =
| 'now_loc'
| 'now_ls'
| 'now_proj'
| 'now_plr'
| 'now_syn_loc'
| 'now_mus'
| 'now_coll_meth'
| 'com_mlist'
| 'com_species'
| 'now_ss'
| 'com_taxa_synonym'
| 'now_time_unit'
| 'now_tu_bound'
| 'ref_ref'
/*
Only tables listed here are supported for write operations
*/
export const ids: Record<AllowedTables, string[]> = {
now_loc: ['lid'],
now_ls: ['lid', 'species_id'],
now_proj: ['pid'],
now_plr: ['lid', 'pid'],
now_syn_loc: ['syn_id'],
now_mus: ['lid', 'museum'],
now_coll_meth: ['lid', 'coll_meth'],
com_mlist: ['museum'],
com_species: ['species_id'],
now_ss: ['lid', 'sed_struct'],
now_time_unit: ['tu_name'],
now_tu_bound: ['bid'],
ref_ref: ['rid'],
com_taxa_synonym: ['synonym_id'],
}
export const supportedTables = Object.keys(ids)
export type Item = { table: AllowedTables; column: string; value: DbValue; oldValue?: DbValue }
export type DeleteItem = { tableName: AllowedTables; idValues: Array<string | number>; idColumns: string[] }
export type WriteItem = {
table: AllowedTables
type: ActionType
items: Item[]
}
// Possible types of values that are read from db or written in there. BigInt is handled as number.
export type DbValue = string | number | null
export const allowedFields: Record<string, boolean | undefined> = {}
const addFieldsToAllowed = (fields: string[]) =>
fields.forEach(f => {
allowedFields[f] = true
})
// Adding tables and fields to allowed field values
addFieldsToAllowed(supportedTables)
supportedTables.forEach(table => addFieldsToAllowed(Object.keys(nowDb[table as AllowedTables].fields)))
// Remove some fields that should be ignored
allowedFields['now_lau'] = false
allowedFields['now_sau'] = false
allowedFields['now_tau'] = false
allowedFields['now_bau'] = false