UNPKG

qb-table

Version:

A lightweight abstraction layer for Quick Base

1,126 lines (905 loc) 26.5 kB
'use strict'; /* Dependencies */ import merge from 'deepmerge'; import RFC4122 from 'rfc4122'; import { QuickBase, QuickBaseOptions, QuickBaseResponseDeleteTable, QuickBaseResponseDeleteRecords, QuickBaseResponseRunQuery, QuickBaseRequestRunQuery, QuickBaseRequest, QuickBaseResponseGetTable, QuickBaseResponseCreateTable, QuickBaseResponseUpdateTable, QuickBaseError } from 'quickbase'; import { QBField, QBFieldJSON, QBFieldAttributeSavable } from 'qb-field'; import { QBFids, QBRecord, QBRecordData, replaceUndefinedWithString } from 'qb-record'; import { QBReport, QBReportRunResponse, QBReportRunRequest } from 'qb-report'; /* Globals */ const VERSION = require('../package.json').version; const IS_BROWSER = typeof(window) !== 'undefined'; const rfc4122 = new RFC4122(); /* Main Class */ export class QBTable< RecordData extends QBRecordData = QBRecordData, CustomGetSet extends Object = Record<any, any> > { public readonly CLASS_NAME = 'QBTable'; static readonly CLASS_NAME = 'QBTable'; /** * The loaded library version */ static readonly VERSION: string = VERSION; /** * The default settings of a `QuickBase` instance */ static defaults: QBTableOptions = { quickbase: { realm: IS_BROWSER ? window.location.host.split('.')[0] : '' }, appId: '', tableId: (() => { if(IS_BROWSER){ const tableId = window.location.pathname.match(/^\/db\/(?!main)(.*)$/); if(tableId){ return tableId[1]; } } return ''; })(), fids: { recordid: 3, primaryKey: 3 } }; /** * An internal id (guid) used for tracking/managing object instances */ public id: string; private _qb: QuickBase; private _appId: string = ''; private _tableId: string = ''; private _fids: Record<any, number> = {}; private _fields: QBField[] = []; private _records: QBRecord<RecordData>[] = []; private _reports: QBReport<RecordData>[] = []; private _data: Record<any, any> = {}; constructor(options?: Partial<QBTableOptions<RecordData>>){ this.id = rfc4122.v4(); const { quickbase, ...classOptions } = options || {}; const settings = merge(QBTable.defaults, classOptions); this.setAppId(settings.appId) .setTableId(settings.tableId) .setFids(settings.fids as Record<any, number>); if(QuickBase.IsQuickBase(quickbase)){ this._qb = quickbase; }else{ this._qb = new QuickBase(merge.all([ QBTable.defaults.quickbase, quickbase || {}, { tempTokenDbid: this.getTableId() } ])); } return this; } clear(): this { this._fields = []; this._records = []; this._reports = []; this._data = { id: '', alias: '', created: 0, updated: 0, name: '', description: '', singleRecordName: '', pluralRecordName: '', timeZone: '', dateFormat: 'MM-DD-YYYY', keyFieldId: 0, nextFieldId: 0, nextRecordId: 0, defaultSortFieldId: 0, defaultSortOrder: '' }; return this; } async delete({ requestOptions }: QuickBaseRequest = {}): Promise<QuickBaseResponseDeleteTable> { const results = await this._qb.deleteTable({ appId: this.getAppId(), tableId: this.getTableId(), requestOptions }); this.setTableId(''); this.clear(); return results; } async deleteRecord({ record, requestOptions }: { record: QBRecord<RecordData> } & QuickBaseRequest): Promise<QuickBaseResponseDeleteRecords> { let i = -1; this.getRecords().some((r, o) => { if(record.id === r.id || (record.get('recordid') && record.get('recordid') === r.get('recordid'))){ i = o; return true; } return false; }); let results = { numberDeleted: 1 }; if(i !== -1){ this._records.splice(i, 1); } if(record.get('recordid')){ results = await record.delete({ requestOptions }); if(results.numberDeleted === 0){ this._records.push(record); } } return results; } async deleteRecords({ individually = false, records, requestOptions }: { individually?: boolean; records?: QBRecord<RecordData>[]; } & QuickBaseRequest = {}): Promise<QuickBaseResponseDeleteRecords> { const results = { numberDeleted: 0 }; if(individually){ if(records === undefined){ records = this.getRecords(); } for(const record of records){ const result = await this.deleteRecord({ record, requestOptions }); results.numberDeleted += result.numberDeleted; } }else{ if(records === undefined){ records = this._records.splice(0, this._records.length); } const batches: QBRecord<RecordData>[][] = records.reduce((batches: QBRecord<RecordData>[][], record: QBRecord<RecordData>) => { if(record.get('recordid') <= 0){ return batches; } if(batches[batches.length - 1].length === 100){ batches.push([]); } batches[batches.length - 1].push(record); return batches; }, [ [] ]); for(let i = 0; i < batches.length; ++i){ const result = await this._qb.deleteRecords({ tableId: this.getTableId(), where: batches[i].map((record) => { return `{'${this.getFid('recordid')}'.EX.'${record.get('recordid')}'}`; }).join('AND'), requestOptions }); results.numberDeleted += result.numberDeleted; } } return results; } get(attribute: 'id' | 'appId' | 'tableId'): string; get<P extends keyof QuickBaseResponseGetTable>(attribute: P): QuickBaseResponseGetTable[P]; get<P extends keyof CustomGetSet>(attribute: P): CustomGetSet[P]; get<P extends string>(attribute: P): P extends keyof QuickBaseResponseGetTable ? QuickBaseResponseGetTable[P] : (P extends keyof CustomGetSet ? CustomGetSet[P] : any); get(attribute: any): any { if(attribute === 'id' || attribute === 'tableId'){ return this.getTableId(); }else if(attribute === 'appId'){ return this.getAppId(); } return this._data[attribute]; } getAppId(): string { return this._appId; } getFid<T extends keyof RecordData>(field: T): number; getFid(field: string | number, byId?: false | undefined): number; getFid(field: number, byId: true): string; getFid(field: string | number, byId: boolean = false): string | number { const fids = this.getFids(); let id: string | number = -1; if(byId !== true){ if(fids.hasOwnProperty(field)){ id = fids[field]; } }else{ id = ''; field = +field; Object.keys(fids).some((name) => { if(fids[name] === field){ id = name; return true; } return false; }); } return id; } getFids(): QBFids<RecordData> { return this._fids as QBFids<RecordData>; } getField(id: number, returnIndex: true): number | undefined; getField(id: number, returnIndex?: false): QBField | undefined; getField(id: number, returnIndex: boolean = false): number | QBField | undefined { const fields = this.getFields(); let result = undefined; for(let i = 0; result === undefined && i < fields.length; ++i){ if(fields[i].getFid() === id){ result = returnIndex ? i : fields[i]; } } return result; } getFields(): QBField[] { return this._fields; } getNRecords(): number { return this._records.length; } getRecord<T extends keyof RecordData>(value: RecordData[T], fieldName: T, returnIndex: true): number; getRecord<T extends keyof RecordData>(value: RecordData[T], fieldName: T, returnIndex?: false): QBRecord<RecordData> | undefined; getRecord(value: any, fieldName: string, returnIndex: true): number; getRecord(value: any, fieldName?: string, returnIndex?: false | undefined): QBRecord<RecordData> | undefined; getRecord(value: any, fieldName: string = 'recordid', returnIndex: boolean = false): QBRecord<RecordData> | number | undefined { const records = this.getRecords(); let i = -1; records.some((record, o) => { if(record.get(fieldName) !== value){ return false; } i = o; return true; }); if(returnIndex){ return i; }else if(i === -1){ return undefined; } return records[i]; } getRecords(): QBRecord<RecordData>[] { return this._records; } getReport(id: string): QBReport<RecordData> | undefined { let result; for(let i = 0; !result && i < this._reports.length; ++i){ if(this._reports[i].getReportId() === id){ result = this._reports[i]; } } return result; } getReports(): QBReport<RecordData>[] { return this._reports; } getTableId(): string { return this._tableId; } async getTempToken({ requestOptions }: QuickBaseRequest = {}): Promise<void> { await this._qb.getTempTokenDBID({ dbid: this.getTableId(), requestOptions }); } async loadField({ field, requestOptions }: QuickBaseRequest & { field: number | QBField }): Promise<QBField> { if(!QBField.IsQBField(field)){ field = this.getField(field) || field; if(!QBField.IsQBField(field)){ field = new QBField({ quickbase: this._qb, tableId: this.getTableId(), fid: field }); this._fields.push(field); } } await field.load({ requestOptions }); return field; } async loadFields({ requestOptions }: QuickBaseRequest = {}): Promise<QBField[]> { const results = await this._qb.getFields({ tableId: this.getTableId(), requestOptions }); results.forEach((field) => { let result = this.getField(field.id); if(!result){ result = new QBField({ quickbase: this._qb, tableId: this.getTableId(), fid: field.id }); this._fields.push(result); } Object.entries(field).forEach(([ attribute, value ]) => { result!.set(attribute, value); }); }); return this.getFields(); } async loadReport({ report, requestOptions }: QuickBaseRequest & { report: string | QBReport<RecordData> }): Promise<QBReport<RecordData>> { if(!QBReport.IsQBReport<RecordData>(report)){ report = this.getReport(report) || report; if(!QBReport.IsQBReport<RecordData>(report)){ report = new QBReport<RecordData>({ quickbase: this._qb, tableId: this.getTableId(), reportId: report }); report.setFids(this.getFids()); this._reports.push(report); } } await report.load({ requestOptions }); return report; } async loadReports({ requestOptions }: QuickBaseRequest = {}): Promise<QBReport<RecordData>[]> { const results = await this._qb.getTableReports({ tableId: this.getTableId(), requestOptions }); this._reports = results.map((report) => { const qbReport = new QBReport<RecordData>({ quickbase: this._qb, tableId: this.getTableId(), reportId: report.id }); Object.entries(report).forEach(([ key, value ]) => { qbReport.set(key, value); }); qbReport.setFids(this.getFids()); return qbReport; }); return this._reports; } async loadSchema({ requestOptions }: QuickBaseRequest = {}): Promise<QuickBaseResponseGetTable & { fields: QBField[], reports: QBReport<RecordData>[] }> { const results = await Promise.all([ this.loadFields({ requestOptions }), this.loadReports({ requestOptions }), this.loadTable({ requestOptions }) ]); return { ...results[2], reports: results[1], fields: results[0] }; } async loadTable({ requestOptions }: QuickBaseRequest = {}): Promise<QuickBaseResponseGetTable> { const results = await this._qb.getTable({ appId: this.getAppId(), tableId: this.getTableId(), requestOptions }); (Object.entries(results) as [ keyof QuickBaseResponseGetTable, any ][]).forEach(([ attribute, value ]) => { this.set(attribute, value); }); return this._data as QuickBaseResponseGetTable; } private async _runQueryAll(query: QuickBaseRequestRunQuery): Promise<QuickBaseResponseRunQuery> { const results: QuickBaseResponseRunQuery = { metadata: { numFields: 0, numRecords: 0, skip: 0, top: 0, totalRecords: 0 }, data: [], fields: [] }; let firstRun = true, skip = query.options?.skip || 0, top = 0, total = 0; while(true){ const batchQuery: QuickBaseRequestRunQuery = { ...query, sortBy: [ ...(query.sortBy || []), { fieldId: 3, order: 'ASC' } ] }; if(!firstRun){ batchQuery.options = { skip, top }; } if(!this._qb.settings.userToken){ await this._qb.getTempTokenDBID({ dbid: batchQuery.tableId }); } const { fields, metadata, data } = await this._qb.runQuery({ ...batchQuery, returnAxios: false }); if(firstRun){ results.fields = fields; results.metadata = metadata; results.data = data; total = metadata.totalRecords; firstRun = false; }else{ results.data = results.data.concat(data); } top = metadata.top || metadata.numRecords || top; // top doesn't always return in metadata skip += metadata.numRecords; if(skip >= total){ break; } } results.metadata.skip = 0; results.metadata.top = results.data.length; results.metadata.numRecords = results.data.length; results.metadata.totalRecords = results.data.length; return results; } async runQuery({ fids, groupBy, options, select, sortBy, where, returnAll, requestOptions }: QBTableRunQueryOptions = {}): Promise<QBTableRunQueryResponse<RecordData>> { if(!fids){ fids = this.getFids(); } const names = Object.keys(fids); const selectedFids = names.reduce((selectedFids, name) => { const fid = fids![name]; if(!select || select.length === 0 || select.indexOf(fid) !== -1){ selectedFids[name] = fid; } return selectedFids; }, {} as Record<string | number, number>); const selectedNames = Object.keys(selectedFids); const query: any = { tableId: this.getTableId(), select: selectedNames.map((name) => { return selectedFids[name]; }).filter(filterUnique), where: where, requestOptions, returnAxios: false }; if(sortBy){ query.sortBy = sortBy; } if(groupBy){ query.groupBy = groupBy; } if(options){ query.options = options; } let results; if(returnAll){ results = await this._runQueryAll(query); }else{ results = await this._qb.runQuery(query); } results.fields.forEach((field) => { let result = this.getField(field.id); if(!result){ result = new QBField({ quickbase: this._qb, tableId: this.getTableId(), fid: field.id }); this._fields.push(result); } Object.entries(field).forEach(([ attribute, value ]) => { result!.set(attribute, value); }); }); const fields = this.getFields(); this._records = results.data.map((record) => { const qbRecord = new QBRecord<RecordData>({ quickbase: this._qb, tableId: this.getTableId(), fids: this.getFids() }); qbRecord.setFields(fields); selectedNames.forEach((name) => { const fid = selectedFids[name]; qbRecord.set('' + (name || fid), record[fid] ? record[fid].value : undefined); }); return qbRecord; }); return { metadata: results.metadata, fields: this.getFields(), records: this.getRecords() }; } async runReport({ report, skip, top, requestOptions }: QBReportRunRequest & { report: string | QBReport<RecordData> }): Promise<QBReportRunResponse<RecordData>>{ if(!QBReport.IsQBReport<RecordData>(report)){ report = this.getReport(report) || report; if(!QBReport.IsQBReport<RecordData>(report)){ report = new QBReport<RecordData>({ quickbase: this._qb, tableId: this.getTableId(), reportId: report }); report.setFids(this.getFids()); this._reports.push(report); } } const results = await report.run({ skip, top, requestOptions }); this._records = report.getRecords(); return results; } async saveFields({ attributesToSave, requestOptions }: { attributesToSave?: QBFieldAttributeSavable[] } & QuickBaseRequest = {}): Promise<QBField[]> { const fields = this.getFields(); for(let i = 0; i < fields.length; ++i){ await fields[i].save({ attributesToSave, requestOptions }); } return fields; } async saveRecords({ individually, fidsToSave, recordsToSave, mergeFieldId, requestOptions }: { individually?: boolean, fidsToSave?: (keyof RecordData | number)[], mergeFieldId?: number, recordsToSave?: QBRecord<RecordData>[] } & QuickBaseRequest = {}): Promise<QBRecord<RecordData>[]> { const records = recordsToSave === undefined ? this.getRecords() : recordsToSave; if(individually){ for(let i = 0; i < records.length; ++i){ await records[i].save({ fidsToSave, requestOptions }); } }else{ const mergeField = mergeFieldId || this.getFid('primaryKey'); const fids = this.getFids(); const names = Object.keys(fids); const selectedNames = names.filter((name) => { const fid = fids[name]; const filtered = !fidsToSave || fidsToSave.indexOf(fid) !== -1 || fidsToSave.indexOf(name) !== -1 || fid === mergeField; if(!filtered){ return false; } const field = this.getField(fid); if(field && [ 'lookup', 'summary', 'formula' ].indexOf(field.get('mode') || '') !== -1){ return false; } return true; }); const inputRecords = records.sort((a, b) => { const aVal = a.get('recordid'); const bVal = b.get('recordid'); if(aVal === bVal){ return 0; }else if(aVal === undefined && bVal !== undefined){ return -1; }else if(aVal !== undefined && bVal === undefined){ return 1; } return aVal > bVal ? 1 : -1; }); const { headers, data: results } = await this._qb.upsert({ tableId: this.getTableId(), mergeFieldId: mergeField, data: inputRecords.map((qbRecord) => { return selectedNames.reduce((record, name) => { const fid = fids[name]; if(fid){ record[fid] = { value: replaceUndefinedWithString(qbRecord.get(name)) }; } return record; }, {} as Record<string,{ value: any }>); }), fieldsToReturn: names.map((name) => { return fids[name]; }).filter(filterUnique), requestOptions, returnAxios: true }); const errors: QuickBaseError[] = []; for(let inputI = 0, dataI = 0; inputI < inputRecords.length; ++inputI){ const record = inputRecords[inputI]; const lineNum = inputI + 1; const error = results.metadata.lineErrors && results.metadata.lineErrors[lineNum]; if(error){ errors.push(new QuickBaseError(207, `Error on Line ${lineNum}`, error.join('\n'), headers['qb-api-ray'])); }else{ const data = record.get('recordid') ? results.data.find((result) => { return result['3'].value === record.get('recordid'); }) : results.data[dataI]; if(data){ names.forEach((name) => { const fid = fids[name]; if(fid){ const field = data[fid]; if(field){ record.set(name, field.value); } } }); } ++dataI; } } if(errors.length > 0){ if(typeof(AggregateError) !== 'undefined'){ throw new AggregateError(errors, 'A partial success response was returned'); }else{ throw new QuickBaseError(207, 'A partial success response was returned', errors.map((err) => { return `${err.message}: ${err.description}`; }).join('\n'), headers['qb-api-ray']); } } } return records; } async saveTable({ attributesToSave, requestOptions }: { attributesToSave?: string[] } & QuickBaseRequest = {}): Promise<QuickBaseResponseGetTable> { const tableId = this.getTableId(); const data = Object.keys(this._data).filter((attribute) => { return [ 'name', 'description', 'iconName', 'singularNoun', 'pluralNoun' ].indexOf(attribute) !== -1 && (!attributesToSave || attributesToSave.indexOf(attribute) === -1); }).reduce((results: any, attribute) => { results[attribute] = this._data[attribute]; return results; }, { appId: this.getAppId(), requestOptions }); let results: QuickBaseResponseCreateTable | QuickBaseResponseUpdateTable; if(tableId){ data.tableId = tableId; results = await this._qb.updateTable(data); }else{ results = await this._qb.createTable(data); } (Object.entries(results) as [ keyof QuickBaseResponseGetTable, any ][]).forEach(([ attribute, value ]) => { this.set(attribute, value); }); return this._data as QuickBaseResponseGetTable; } set(attribute: 'id' | 'tableId' | 'appid', value: string): this; set<P extends keyof QuickBaseResponseGetTable>(attribute: P, value: QuickBaseResponseGetTable[P]): this; set<P extends keyof CustomGetSet>(attribute: P, value: CustomGetSet[P]): this; set<P extends string>(attribute: P, value: P extends keyof QuickBaseResponseGetTable ? QuickBaseResponseGetTable[P] : (P extends keyof CustomGetSet ? CustomGetSet[P] : any)): this; set(attribute: any, value: any): this { if(attribute === 'id' || attribute === 'tableId'){ return this.setTableId(value); }else if(attribute === 'appid'){ return this.setAppId(value); } this._data[attribute] = value; return this; } setAppId(appId: string): this { this._appId = appId; return this; } setFid<T extends keyof RecordData>(name: T, id: number): this; setFid(name: string | number, id: number): this; setFid(name: string | number, id: number): this { this._fids[name] = +id; return this; } setFids(fields: Record<any, number>): this { Object.entries(fields).forEach(([ name, fid ]) => { this.setFid(name, fid); }); return this; } setTableId(tableId: string): this { this._tableId = tableId; this._data.id = tableId; return this; } async upsertField(options: QBField | Partial<QBFieldJSON['data']>, autoSave: boolean = false): Promise<QBField> { let field: QBField | undefined; if(QBField.IsQBField(options)){ if(options.get('recordid')){ field = this.getField(options.get('fid')); }else if(options.get('primaryKey')){ field = this.getField(options.get('fid')); }else{ field = options; } }else if(options !== undefined){ if(options.fid){ field = this.getField(options.fid); }else if(options.id){ field = this.getField(options.id); } } if(!field){ field = new QBField({ quickbase: this._qb, tableId: this.getTableId(), fid: -1 }); if(options && !QBField.IsQBField(options) && options.fid){ field.setFid(options.fid); } this._fields.push(field); } if(options && !QBField.IsQBField(options)){ Object.entries(options).forEach(([ attribute, value ]) => { field!.set(attribute, value); }); } if(autoSave){ await field.save(); } return field; } async upsertFields(fields: (Partial<QBFieldJSON> | QBField)[], autoSave: boolean = false): Promise<QBField[]>{ const results = []; for(let i = 0; i < fields.length; ++i){ results.push(await this.upsertField(fields[i], autoSave)); } return results; } async upsertRecord(options?: QBRecord<RecordData> | Partial<RecordData>, autoSave: boolean = false): Promise<QBRecord<RecordData>> { let record: QBRecord<RecordData> | undefined; if(QBRecord.IsQBRecord<RecordData>(options)){ if(options.get('recordid')){ record = this.getRecord(options.get('recordid'), 'recordid'); }else if(options.get('primaryKey')){ record = this.getRecord(options.get('primaryKey'), 'primaryKey'); }else{ record = options; } }else if(options !== undefined){ if(options.recordid){ record = this.getRecord(options.recordid, 'recordid'); }else if(options.primaryKey){ record = this.getRecord(options.primaryKey, 'primaryKey'); } } if(!record){ record = new QBRecord<RecordData>({ quickbase: this._qb, tableId: this.getTableId(), fids: this.getFids() }); this._records.push(record); } record.setFields(this.getFields()); if(options && !QBRecord.IsQBRecord<RecordData>(options)){ const addDefaults = !record.get('recordid'); Object.entries(options).forEach(([ fidName, fidValue ]) => { let value; if(addDefaults){ const fid = this.getFid(fidName); const field = this.getField(fid); if(field){ value = field.get('properties')?.defaultValue; } if(fidValue !== undefined){ value = fidValue; } }else{ value = fidValue; } record!.set(fidName, value); }); } if(autoSave){ await record.save(); } return record; } async upsertRecords(records: (QBRecord<RecordData> | Partial<RecordData>)[], autoSave: boolean = false): Promise<QBRecord<RecordData>[]>{ const results = []; for(let i = 0; i < records.length; ++i){ results.push(await this.upsertRecord(records[i], autoSave)); } return results; } /** * Test if a variable is a `qb-record` object * * @param obj A variable you'd like to test */ static IsQBTable<T extends QBRecordData = QBRecordData, K extends Object = Record<any, any>>(obj: any): obj is QBTable<T, K> { return ((obj || {}) as QBTable).CLASS_NAME === QBTable.CLASS_NAME; } static NewRecord<T extends QBRecordData, K extends Object>(table: QBTable<T, K>, data?: Partial<T>){ return QBRecord.NewRecord<T>({ quickbase: table._qb, tableId: table.getTableId(), fids: table.getFids() }, data); } static ToCSV<T extends QBRecordData, K extends Object>(table: QBTable<T, K>, columns: (keyof T)[], data?: QBRecord<T>[]): string { return (data || table.getRecords()).map((record) => { return columns.map((field) => { const value = record.get(field); switch(typeof(value)){ case 'number': return value; case 'boolean': return `"${value ? 'yes' : 'no'}"`; case 'object': return `"${JSON.stringify(value).replace(/"/g, '""')}"`; default: return `"${value.replace(/"/g, '""')}"`; } }).join(','); }).join('\n'); } } /* Helpers */ const filterUnique = (val: any, i: number, arr: any[]) => { return arr.indexOf(val) === i; }; /* Interfaces */ export type QBTableOptions<RecordData extends QBRecordData = {}> = { quickbase: QuickBaseOptions | QuickBase; appId: string; tableId: string; fids: Partial<QBFids<RecordData>>; } export type QBTableRunQueryOptions = { fids?: Record<string, number>; returnAll?: boolean; } & Pick<QuickBaseRequestRunQuery, 'select' | 'where' | 'options' | 'sortBy' | 'groupBy' | 'requestOptions'> export type QBTableRunQueryResponse<RecordData extends QBRecordData = {}> = Pick<QuickBaseResponseRunQuery, 'metadata'> & { records: QBRecord<RecordData>[]; fields: QBField[]; }; /* Export to Browser */ if(IS_BROWSER){ window.QBTable = exports; }