|
- /* 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
- }
|