UNPKG

@clickup/ent-framework

Version:

A PostgreSQL graph-database-alike library with microsharding and row-level security

191 lines (174 loc) 6.33 kB
import uniq from "lodash/uniq"; import type { Query } from "../abstract/Query"; import type { QueryAnnotation } from "../abstract/QueryAnnotation"; import type { Schema } from "../abstract/Schema"; import { stringHash } from "../internal/misc"; import type { Field, Table, UpdateInput } from "../types"; import { ID } from "../types"; import type { PgClient } from "./PgClient"; import { PgRunner } from "./PgRunner"; export class PgQueryUpdate<TTable extends Table> implements Query<boolean> { private readonly allFields; readonly input: UpdateInput<TTable> & { [ID]: string }; readonly IS_WRITE = true; constructor( public readonly schema: Schema<TTable>, id: string, input: UpdateInput<TTable>, ) { // A little hack to merge the updating row with its ID. this.input = { ...input, [ID]: id }; this.allFields = Object.keys(this.schema.table); } async run(client: PgClient, annotation: QueryAnnotation): Promise<boolean> { // Treat undefined as an absent key. This will hopefully be JITed very // efficiently, but could still be that it won't since we enumerate object // keys and use [] to access the values. const fields = this.allFields.filter( (field) => field !== ID && this.input[field] !== undefined, ); const casFields = this.input.$cas ? this.allFields.filter( (field) => field !== ID && this.input.$cas![field] !== undefined, ) : []; // If there are no known fields to update, skip the entire operation. We // return true since we don't know whether the row is in the DB or not, so // we assume it is. if (fields.length === 0 && !this.input.$literal) { return true; } // An UPDATE with $literal is a little hacky: we disable batching for it, // because we can't guarantee that the SET clause in "WITH ... VALUES ... // UPDATE ... SET ... FROM rows" batched query will be identical for all // input rows. const disableBatching = !!this.input.$literal; // Since UPDATE has partial list of fields, we have to cache runners per // updating fields list. Else we'd not be able to do a partial batched update. return client .batcher( this.constructor, this.schema, fields.join(":") + ":" + casFields.join(":"), disableBatching, () => // This is run only once per every unique combination of field names, // not per every row updated, so it's cheap to do whatever we want. new PgRunnerUpdate<TTable>(this.schema, client, fields, casFields), ) .run(this.input, annotation); } } class PgRunnerUpdate<TTable extends Table> extends PgRunner< TTable, UpdateInput<TTable> & { [ID]: string }, boolean > { static override readonly IS_WRITE = true; private singleBuilder; private batchBuilder; readonly op = "UPDATE"; readonly maxBatchSize = 100; readonly default = false; // If nothing is updated, we return false. constructor( schema: Schema<TTable>, client: PgClient, fieldsIn: Array<Field<TTable>>, casFieldsIn: Array<Field<TTable>>, ) { super(schema, client); // Always include all autoUpdate fields. const fields = uniq([ ...fieldsIn, ...Object.keys(this.schema.table).filter( (field) => this.schema.table[field].autoUpdate !== undefined, ), ]); const casFields = casFieldsIn.map((field) => ({ field, alias: `$cas.${field}`, })); this.singleBuilder = { prefix: this.fmt("UPDATE %T SET "), func1: this.createUpdateKVsBuilder(fields), midfix: this.fmt(" WHERE %PK="), func2: (input: { [ID]: string }) => this.escapeValue(ID, input[ID]), cas: casFields.length > 0 ? this.createValuesBuilder({ prefix: this.fmt(" AND ROW(%FIELDS) IS NOT DISTINCT FROM ROW", { fields: casFields.map(({ field }) => field), normalize: true, }), indent: "", fields: casFields, suffix: "", }) : null, suffix: this.fmt(` RETURNING %PK AS ${ID}`), }; // There can be several updates for same id (due to batching), so returning // all keys here. this.batchBuilder = this.createWithBuilder({ fields: [...this.addPK(fields, "prepend"), ...casFields], suffix: this.fmt( "UPDATE %T SET %UPDATE_FIELD_VALUE_PAIRS(rows)\n" + "FROM rows WHERE %PK(%T)=%PK(rows)", { fields }, ) + (casFields.length > 0 ? " AND " + this.fmt("ROW(%FIELDS(%T))", { fields: casFields.map(({ field }) => field), normalize: true, }) + " IS NOT DISTINCT FROM " + this.fmt("ROW(%FIELDS(rows))", { fields: casFields }) : "") + this.fmt(" RETURNING rows._key"), }); } override key(input: UpdateInput<TTable> & { [ID]: string }): string { return ( input[ID] + (input.$cas ? ":" + stringHash(JSON.stringify(input.$cas)) : "") ); } async runSingle( input: UpdateInput<TTable> & { [ID]: string }, annotations: QueryAnnotation[], ): Promise<boolean> { const literal = input.$literal; const sql = this.singleBuilder.prefix + this.singleBuilder.func1(input, literal) + this.singleBuilder.midfix + this.singleBuilder.func2(input) + (this.singleBuilder.cas?.prefix ?? "") + (this.singleBuilder.cas?.func?.([["", input]]) ?? "") + (this.singleBuilder.cas?.suffix ?? "") + this.singleBuilder.suffix; const rows = await this.clientQuery<{ [ID]: string }>(sql, annotations, 1); return rows.length > 0 ? true : false; } async runBatch( inputs: Map<string, UpdateInput<TTable> & { [ID]: string }>, annotations: QueryAnnotation[], ): Promise<Map<string, boolean>> { const sql = this.batchBuilder.prefix + this.batchBuilder.func(inputs) + this.batchBuilder.suffix; const rows = await this.clientQuery<{ _key: string; [ID]: string }>( sql, annotations, inputs.size, ); const outputs = new Map<string, boolean>(); for (const row of rows) { outputs.set(row._key, true); } return outputs; } }