qb-record
Version:
A lightweight abstraction layer for Quick Base
593 lines (474 loc) • 13.2 kB
text/typescript
'use strict';
/* Dependencies */
import merge from 'deepmerge';
import RFC4122 from 'rfc4122';
import {
QuickBase,
QuickBaseError,
QuickBaseOptions,
QuickBaseRequest,
QuickBaseResponseDeleteRecords
} from 'quickbase';
import {
QBField,
QBFieldJSON
} from 'qb-field';
/* Globals */
const VERSION = require('../package.json').version;
const IS_BROWSER = typeof(window) !== 'undefined';
const rfc4122 = new RFC4122();
/* Main Class */
export class QBRecord<RecordData extends QBRecordData = QBRecordData> {
public readonly CLASS_NAME = 'QBRecord';
static readonly CLASS_NAME = 'QBRecord';
static readonly VERSION: string = VERSION;
/**
* The default settings of a `QuickBase` instance
*/
static defaults: QBRecordOptions = {
quickbase: {
realm: IS_BROWSER ? window.location.host.split('.')[0] : ''
},
tableId: (() => {
if(IS_BROWSER){
const tableId = window.location.pathname.match(/^\/db\/(?!main)(.*)$/);
if(tableId){
return tableId[1];
}
}
return '';
})(),
fids: {
recordid: 3,
primaryKey: 3
},
recordid: undefined,
primaryKey: undefined
};
/**
* An internal id (guid) used for tracking/managing object instances
*/
public id: string;
private _qb: QuickBase;
private _tableId: string = '';
private _fids: Record<any, number> = {};
private _fields: QBField[] = [];
private _data: Record<any, any> = {};
constructor(options?: Partial<QBRecordOptions<RecordData>>){
this.id = rfc4122.v4();
const {
quickbase,
...classOptions
} = options || {};
if(QuickBase.IsQuickBase(quickbase)){
this._qb = quickbase;
}else{
this._qb = new QuickBase(merge.all([
QBRecord.defaults.quickbase,
quickbase || {}
]));
}
const settings = merge(QBRecord.defaults, classOptions);
this.setTableId(settings.tableId)
.setFids(settings.fids as Record<any, number>)
// @ts-ignore - my typescript skills fail me for now, tests are fine though
.set('recordid', settings.recordid)
// @ts-ignore - my typescript skills fail me for now, tests are fine though
.set('primaryKey', settings.primaryKey);
return this;
}
clear(): this {
this._data = {};
this._fields = [];
return this;
}
async delete({ requestOptions }: QuickBaseRequest = {}): Promise<QuickBaseResponseDeleteRecords> {
const recordid = this.get('recordid');
if(recordid){
const results = await this._qb.deleteRecords({
tableId: this.getTableId(),
where: `{'${this.getFid('recordid')}'.EX.'${recordid}'}`,
requestOptions
});
if(results.numberDeleted !== 0){
this.clear();
}
return results;
}else{
this.clear();
return {
numberDeleted: 0
};
}
}
get<F extends keyof RecordData>(field: F): RecordData[F];
get<F extends string>(field: F): F extends keyof RecordData ? RecordData[F] : any;
get(field: any): any {
return this._data[field];
}
getFid<T extends keyof RecordData>(field: T): number;
getFid(field: number, byId: true): string;
getFid(field: string | number, byId?: false): number;
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.entries(fids).some(([ name, fid ]) => {
if(fid === 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;
}
getTableId(): string {
return this._tableId;
}
async getTempToken({ requestOptions }: QuickBaseRequest = {}): Promise<void> {
await this._qb.getTempTokenDBID({
dbid: this.getTableId(),
requestOptions
});
}
async load({ clist, query, requestOptions }: QBRecordLoad = {}): Promise<Record<any, any>> {
const where = [];
let fids = this.getFids() as Record<any, any>;
let select = [];
if(query){
where.push(`(${query})`);
}
if(this.get('recordid')){
where.push(`{'${this.getFid('recordid')}'.EX.'${this.get('recordid')}'}`);
}else
if(this.get('primaryKey')){
where.push(`{'${this.getFid('primaryKey')}'.EX.'${this.get('primaryKey')}'}`);
}
if(clist){
if(typeof(clist) === 'string'){
select = clist.split('.').map((val: string) => +val);
}else{
select = clist;
}
fids = select.reduce((fids, fid) => {
let name: string | number = this.getFid(fid, true);
if(!name){
name = fid;
this.setFid(fid, fid);
}
fids[name] = fid;
return fids;
}, {} as Record<any, any>);
}else{
select = Object.entries(fids).map((fid) => {
return fid[1];
});
}
const results = await this._qb.runQuery({
tableId: this.getTableId(),
where: where.join('AND'),
select: select,
requestOptions
});
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, property ]) => {
result!.set(attribute, property);
});
});
if(results.metadata.totalRecords === 0){
throw new Error('Record not found');
}
const record = results.data[0];
Object.entries(fids).forEach(([ name, fid ]) => {
this.set(name, record[fid].value);
});
return this._data;
}
async loadSchema({ 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 === undefined){
result = new QBField({
quickbase: this._qb,
tableId: this.getTableId(),
fid: field.id
});
this._fields.push(result);
}
Object.entries(field).forEach(([ property, value ]) => {
result!.set(property, value);
});
});
return this.getFields();
}
async save({
fidsToSave,
mergeFieldId,
requestOptions
}: QuickBaseRequest & {
fidsToSave?: (keyof RecordData | number)[];
mergeFieldId?: number;
} = {}): Promise<Record<any, any>> {
const fids = this.getFids();
const names = Object.entries(fids).map(([ name ]) => name);
const mergeField = mergeFieldId || this.getFid('primaryKey');
const {
headers,
data: results
} = await this._qb.upsert({
tableId: this.getTableId(),
mergeFieldId: mergeField,
data: [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;
}).reduce((record, name) => {
const fid = fids[name];
if(fid){
record[fid] = {
value: replaceUndefinedWithString(this.get(name))
};
}
return record;
}, {} as Record<string, { value: any }>)],
fieldsToReturn: names.map((name) => {
return fids[name];
}).filter((val, i, arr) => {
return arr.indexOf(val) === i;
}),
requestOptions,
returnAxios: true
});
const error = typeof(results.metadata.lineErrors) !== 'undefined' ? results.metadata.lineErrors[1] : false;
if(error){
throw new QuickBaseError(207, 'A partial success response was returned', error.join('\n'), headers['qb-api-ray']);
}
const record = results.data[0];
names.forEach((name) => {
let value = undefined;
const fid = fids[name];
if(fid){
const field = record[fid];
if(field){
value = field.value;
}
}
this.set(name, value);
});
return this._data;
}
set<F extends keyof RecordData>(field: F, value: RecordData[F]): this;
set<F extends string>(field: F, value: F extends keyof RecordData ? RecordData[F] : any): this;
set(field: any, value: any): this {
this._data[field] = value;
return this;
}
setTableId(tableId: string): this {
this._tableId = tableId;
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;
}
setFields(fields: QBField[]): this {
this._fields = fields;
return this;
}
/**
* Rebuild the QBRecord instance from serialized JSON
*
* @param json QBRecord serialized JSON
*/
fromJSON(json: string | QBRecordJSON<RecordData>): this {
if(typeof(json) === 'string'){
json = JSON.parse(json);
}
if(typeof(json) !== 'object'){
throw new TypeError('json argument must be type of object or a valid JSON string');
}
if(json.quickbase){
this._qb = new QuickBase(json.quickbase);
}
if(json.tableId){
this.setTableId(json.tableId);
}
if(json.fids){
this.setFids(json.fids);
}
if(json.recordid){
// @ts-ignore - my typescript skills fail me for now, tests are fine though
this.set('recordid', json.recordid);
}
if(json.primaryKey){
// @ts-ignore - my typescript skills fail me for now, tests are fine though
this.set('primaryKey', json.primaryKey);
}
if(json.fields){
json.fields.forEach((fieldJSON) => {
this._fields.push(QBField.fromJSON(fieldJSON));
});
}
if(json.data){
Object.entries(json.data).forEach(([ property, value ]) => {
this.set(property, value);
});
}
return this;
}
/**
* Serialize the QBRecord instance into JSON
*/
toJSON(fidsToConvert?: (string | number)[]): QBRecordJSON<RecordData> {
return {
quickbase: this._qb.toJSON(),
tableId: this.getTableId(),
fids: this.getFids(),
recordid: this.get('recordid'),
primaryKey: this.get('primaryKey'),
fields: this.getFields().map((field) => {
return field.toJSON();
}),
data: Object.entries(this._data).filter(([ name ]) => {
return !fidsToConvert || fidsToConvert.indexOf(name) !== -1;
}).reduce((data, [ name, value ]) => {
data[name] = value;
return data;
}, {} as Record<any, any>)
};
}
/**
* Create a new QBRecord instance from serialized JSON
*
* @param json QBRecord serialized JSON
*/
static fromJSON<T extends QBRecordData = QBRecordData>(json: string | QBRecordJSON<T>): QBRecord<T> {
if(typeof(json) === 'string'){
json = JSON.parse(json);
}
if(typeof(json) !== 'object'){
throw new TypeError('json argument must be type of object or a valid JSON string');
}
const newRecord = new QBRecord();
return newRecord.fromJSON(json);
}
/**
* Test if a variable is a `qb-record` object
*
* @param obj A variable you'd like to test
*/
static IsQBRecord<T extends QBRecordData = QBRecordData>(obj: any): obj is QBRecord<T> {
return ((obj || {}) as QBRecord).CLASS_NAME === QBRecord.CLASS_NAME;
}
/**
* Returns a new QBRecord instance built off of `options`, that inherits configuration data from the passed in `data` argument.
*
* @param options QBRecord instance options
* @param data Quick Base Record data
*/
static NewRecord<T extends QBRecordData = QBRecordData>(options: Partial<QBRecordOptions<T>>, data?: Partial<T>): QBRecord<T> {
const newRecord = new QBRecord<T>(options);
if(data){
Object.entries(data).forEach(([ property, value ]) => {
newRecord.set(property, value);
});
}
return newRecord;
};
}
/* Helpers */
export const replaceUndefinedWithString = (val: any) => {
return val === null || val === undefined || (typeof val === 'number' && isNaN(val)) ? '' : val
};
/* Types */
export type QBRecordLoad = QuickBaseRequest & {
query?: string;
clist?: string|number[];
}
export type QBRecordData = Record<any, any>;
export type QBFids<T extends QBRecordData> = {
[K in keyof T]: number;
};
export type QBRecordOptions<RecordData extends QBRecordData = {
recordid: number;
primaryKey: number;
}> = {
quickbase: QuickBase | QuickBaseOptions;
tableId: string;
fids: Partial<QBFids<RecordData>>,
recordid?: number;
primaryKey?: number;
}
export type QBRecordJSON<RecordData extends QBRecordData = {
recordid: number;
primaryKey: number;
}> = {
quickbase: QuickBaseOptions;
tableId: string;
fids: QBFids<RecordData>;
recordid: string | number;
primaryKey: string | number;
fields: QBFieldJSON[];
data: RecordData;
}
/* Export to Browser */
if(IS_BROWSER){
window.QBRecord = exports;
}