/* building a model by postgrest * * a model is simply a cache layer between client * and the database behind an api * * options: { * endpoint: 'resource location', * selects: [ * { * 'label': 'column name', * 'alias': 'displayed name', * 'processor': v => process(v) * } * ] * } */ export default (options={}) => { // TODO // 1. sigular or prural // sanity checks if (typeof options.api == 'undefined' || typeof options.endpoint == 'undefined') throw 'api and endpoint are required options' // private model configs let _configs = { // url part api: options.api, endpoint: options.endpoint, endpoint_type: options.endpoint.startsWith('/rpc') ? 'function' : 'relation', // query part selects: options.selects, wheres: options.wheres, order: options.order } // private model cache & meta-info let _cache = { // upstream (data) cache data: null, count: null, upstream_limit: null, // local cache ambient: null, last_ambient: null, } // some random variables to make things work let _xhr = null let _promise = null // construct the model let _model = { // reflection methods configs() { return _configs }, cache() { return _cache }, data(offset=0, limit=Infinity) { return _cache.data && _cache.data.slice(offset, offset+limit) || [] }, ambient_changed() { return _cache.ambient != _cache.last_ambient }, reset() { let ambient_queries = [ ...(_configs.selects ? _configs.selects : []), ...(_configs.wheres ? _configs.wheres.map(where => ({label: where.label, op: where.op, value: typeof where.value == 'function' ? where.value() : where.value})) : []), ...(_configs.order ? _configs.order : []) ] _cache.last_ambient = _cache.ambient _cache.ambient =JSON.stringify(ambient_queries) if (this.ambient_changed()) { _cache.data = [] _cache.count = null _cache.upstream_limit = null _xhr = null //this.fully_loaded = false } }, // main methods select(offset=0, limit=Infinity) { // normalize limit if (limit == Infinity) limit = _cache.upstream_limit || _cache.count || Infinity // be lazy 1: if the data is presented, return the value immediately if (this.data(offset, limit).length > 0 && !this.data(offset, limit).includes(undefined)) return Promise.resolve(this.data(offset, limit)) // be lazy 2: if there is a promise, return it if ambient matches or // cancel it if (_promise != null) return _promise // TODO // ambient = select + order + limit/offset // if ambient is changed, cancel currently loading xhr; otherwise return // current promise // now the hard work _promise = _configs.api.request({ method: 'GET', url: _configs.endpoint, headers: _cache.count == null ? { Prefer: 'count=exact' } : {}, config: xhr => _xhr = xhr, queries: [ // transform model state to postgest queries // selects ...(_configs.selects ? [{ label: 'select', value: _configs.selects.map(select => select.alias ? select.alias + ':' + select.label : select.label).join(',') }] : []), ...(_configs.wheres ? _configs.wheres : []), // order ...(_configs.order ? [{ label: 'order', value: _configs.order .map(o => [ o.label, o.direction, o.nulls ? 'nulls'+o.nulls : '' ].filter(a => a).join('.')) .join(',') }] : []), // limit/offset offset == 0 ? undefined : {label: 'offset', value: offset}, limit == Infinity ? undefined : {label: 'limit', value: limit} ] }).then(response => { // gather begin/end/count let [_range, _count] = _xhr.getResponseHeader('content-range').split('/') let [_begin, _end] = _range.split('-').map(v => ~~v) // update count if presented if (_count != '*') _cache.count = _count // see if an upstream limit is exposed if (_end - _begin + 1 < limit) _cache.upstream_limit = _end - _begin + 1 // fill the data cache response.forEach((data, i) => { // process values _configs.selects && _configs.selects .filter(select => select.processor) .forEach(select => data[select.alias || select.label] = select.processor(data[select.alias || select.label])) // save the data _cache.data[_begin + i] = data }) // assert offset/limit and returned range if (offset != _begin || _end - _begin + 1 > limit) throw 'The request and response data range mismatches!' if (_end - _begin + 1 < limit) console.warn('The response range is narrower than requested, probably due to an upstream hard limit.') // clean model state _promise = null this.reset() // return data return _cache.data.slice(_begin, _end + 1) }) return _promise }, select_all() { // be lazy 1: if the data is presented, return the value immediately if (this.data().length == _cache.count && !this.data().includes(undefined)) return Promise.resolve(this.data()) // be lazy 2: if there is a promise, return it if ambient matches or // cancel it if (_promise != null) return _promise _promise = _configs.api.request({ method: 'POST', url: '/rpc/select_all', body: { endpoint: _configs.endpoint, selects: _configs.selects || [], wheres: _configs.wheres ? _configs.wheres.map(where => ({label: where.label, op: where.op, value: typeof where.value == 'function' ? where.value() : where.value})) : [], order: _configs.order } }).then(response => { _cache.count = response.length _cache.data = response.map(data => { // process values _configs.selects && _configs.selects .filter(select => select.processor) .forEach(select => data[select.alias || select.label] = select.processor(data[select.alias || select.label])) return data }) // clean state _promise = null this.reset() }) return _promise }, export(options={}) { let _data = this.data() if (options.type == 'csv') { let headers = _configs.selects && _configs.selects.map(select => select.alias || select.label) || Object.keys(_data[0]) let body = _data.map(row => headers.map(key => row[key]).join(',')).join('\n') _data = '\ufeff' + headers.join(',') + '\n' + body options.type = 'text/csv' } let blob = new Blob([_data], {type: options.type}) let url = URL.createObjectURL(blob) options.filename = options.filename || _configs.endpoint if (options.filename_suffix) { if (options.filename_suffix == 'datetime') options.filename = options.filename + (new Date()).toLocaleTimeString(undefined, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) else if (options.filename_suffix == 'date') options.filename = options.filename + (new Date()).toLocaleDateString(undefined, { year: "numeric", month: "2-digit", day: "2-digit" }) } let anchor = document.createElement('a') anchor.href = url anchor.target = '_blank' anchor.download = `${options.filename}.csv` anchor.click() URL.revokeObjectURL(url) anchor.remove() } } // initialize model _model.reset() return _model }