You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

277 lines
7.6 KiB

  1. /* building a model by postgrest
  2. *
  3. * a model is simply a cache layer between client
  4. * and the database behind an api
  5. *
  6. * options: {
  7. * endpoint: 'resource location',
  8. * selects: [
  9. * {
  10. * 'label': 'column name',
  11. * 'alias': 'displayed name',
  12. * 'processor': v => process(v)
  13. * }
  14. * ]
  15. * }
  16. */
  17. export default (options={}) => {
  18. // TODO
  19. // 1. sigular or prural
  20. // sanity checks
  21. if (typeof options.api == 'undefined' || typeof options.endpoint == 'undefined')
  22. throw 'api and endpoint are required options'
  23. // private model configs
  24. let _configs = {
  25. // url part
  26. api: options.api,
  27. endpoint: options.endpoint,
  28. endpoint_type: options.endpoint.startsWith('/rpc') ? 'function' : 'relation',
  29. // query part
  30. selects: options.selects,
  31. wheres: options.wheres,
  32. order: options.order
  33. }
  34. // private model cache & meta-info
  35. let _cache = {
  36. // upstream (data) cache
  37. data: null,
  38. count: null,
  39. upstream_limit: null,
  40. // local cache
  41. ambient: null,
  42. last_ambient: null,
  43. }
  44. // some random variables to make things work
  45. let _state = {
  46. xhr: null,
  47. promise: null,
  48. loading: false
  49. }
  50. // construct the model
  51. let _model = {
  52. // reflection methods
  53. configs() { return _configs },
  54. cache() { return _cache },
  55. data(offset=0, limit=Infinity) { return _cache.data && _cache.data.slice(offset, offset+limit) || [] },
  56. ambient_changed() {
  57. return _cache.ambient != _cache.last_ambient
  58. },
  59. reset() {
  60. let ambient_queries = [
  61. ...(_configs.selects ? _configs.selects : []),
  62. ...(_configs.wheres ? _configs.wheres.map(where => ({label: where.label, op: where.op, value: typeof where.value == 'function' ? where.value() : where.value})) : []),
  63. ...(_configs.order ? _configs.order : [])
  64. ]
  65. _cache.last_ambient = _cache.ambient
  66. _cache.ambient =JSON.stringify(ambient_queries)
  67. if (this.ambient_changed()) {
  68. _cache.data = []
  69. _cache.count = null
  70. _cache.upstream_limit = null
  71. _state.xhr = null
  72. //this.fully_loaded = false
  73. }
  74. },
  75. // main methods
  76. select(offset=0, limit=Infinity) {
  77. // normalize limit
  78. if (limit == Infinity)
  79. limit = _cache.upstream_limit || _cache.count || Infinity
  80. // be lazy 1: if the data is presented, return the value immediately
  81. if (this.data(offset, limit).length > 0 && !this.data(offset, limit).includes(undefined))
  82. return Promise.resolve(this.data(offset, limit))
  83. // be lazy 2: if there is a promise, return it if ambient matches or
  84. // cancel it
  85. if (_state.promise != null)
  86. return _state.promise
  87. // TODO
  88. // ambient = select + order + limit/offset
  89. // if ambient is changed, cancel currently loading xhr; otherwise return
  90. // current promise
  91. // now the hard work
  92. _state.promise = _configs.api.request({
  93. method: 'GET',
  94. url: _configs.endpoint,
  95. headers: _cache.count == null ? {
  96. Prefer: 'count=exact'
  97. } : {},
  98. config: xhr => _state.xhr = xhr,
  99. queries: [
  100. // transform model state to postgest queries
  101. // selects
  102. ...(_configs.selects ? [{
  103. label: 'select',
  104. value: _configs.selects.map(select => select.alias ? '"' + select.alias + '":"' + select.label + '"' : '"' + select.label + '"').join(',')
  105. }] : []),
  106. ...(_configs.wheres ? _configs.wheres : []),
  107. // order
  108. ...(_configs.order ? [{
  109. label: 'order',
  110. value: _configs.order
  111. .map(o => [
  112. o.label,
  113. o.direction,
  114. o.nulls ? 'nulls'+o.nulls : ''
  115. ].filter(a => a).join('.'))
  116. .join(',')
  117. }] : []),
  118. // limit/offset
  119. offset == 0 ? undefined : {label: 'offset', value: offset},
  120. limit == Infinity ? undefined : {label: 'limit', value: limit}
  121. ]
  122. }).then(response => {
  123. // gather begin/end/count
  124. let [_range, _count] = _state.xhr.getResponseHeader('content-range').split('/')
  125. let [_begin, _end] = _range.split('-').map(v => ~~v)
  126. // update count if presented
  127. if (_count != '*') _cache.count = _count
  128. // see if an upstream limit is exposed
  129. if (_end - _begin + 1 < limit) _cache.upstream_limit = _end - _begin + 1
  130. // fill the data cache
  131. response.forEach((data, i) => {
  132. // process values
  133. _configs.selects && _configs.selects
  134. .filter(select => select.processor)
  135. .forEach(select => data[select.alias || select.label] = select.processor(data[select.alias || select.label]))
  136. // save the data
  137. _cache.data[_begin + i] = data
  138. })
  139. // assert offset/limit and returned range
  140. if (offset != _begin || _end - _begin + 1 > limit)
  141. throw 'The request and response data range mismatches!'
  142. if (_end - _begin + 1 < limit)
  143. console.warn('The response range is narrower than requested, probably due to an upstream hard limit.')
  144. // clean model state
  145. _state.promise = null
  146. this.reset()
  147. // return data
  148. return _cache.data.slice(_begin, _end + 1)
  149. })
  150. return _state.promise
  151. },
  152. select_all() {
  153. // be lazy 1: if the data is presented, return the value immediately
  154. if (this.data().length == _cache.count && !this.data().includes(undefined))
  155. return Promise.resolve(this.data())
  156. // be lazy 2: if there is a promise, return it if ambient matches or
  157. // cancel it
  158. if (_state.promise != null)
  159. return _state.promise
  160. _state.promise = _configs.api.request({
  161. method: 'POST',
  162. url: '/rpc/select_all',
  163. body: {
  164. endpoint: _configs.endpoint,
  165. selects: _configs.selects || [],
  166. wheres: _configs.wheres ? _configs.wheres.map(where => ({label: where.label, op: where.op, value: typeof where.value == 'function' ? where.value() : where.value})) : [],
  167. order: _configs.order
  168. }
  169. }).then(response => {
  170. _cache.count = response.length
  171. _cache.data = response.map(data => {
  172. // process values
  173. _configs.selects && _configs.selects
  174. .filter(select => select.processor)
  175. .forEach(select => data[select.alias || select.label] = select.processor(data[select.alias || select.label]))
  176. return data
  177. })
  178. // clean state
  179. _state.promise = null
  180. this.reset()
  181. })
  182. return _state.promise
  183. },
  184. export(options={}) {
  185. let _data = this.data()
  186. if (typeof options.filter == 'function') {
  187. _data = options.filter(_data)
  188. }
  189. if (options.type == 'csv') {
  190. let headers = _configs.selects && _configs.selects.map(select => select.alias || select.label) || Object.keys(_data[0])
  191. if (options.generated_columns) {
  192. options.generated_columns.forEach(column => {
  193. headers.splice(column.position, 0, column.label)
  194. })
  195. }
  196. let body = _data.map(row => headers.map(key => row[key]).join(',')).join('\n')
  197. _data = '\ufeff' + headers.join(',') + '\n' + body
  198. options.type = 'text/csv'
  199. }
  200. let blob = new Blob([_data], {type: options.type})
  201. let url = URL.createObjectURL(blob)
  202. options.filename = options.filename || _configs.endpoint
  203. if (options.filename_suffix) {
  204. if (options.filename_suffix == 'datetime')
  205. options.filename = options.filename + (new Date()).toLocaleTimeString(undefined, {
  206. year: "numeric",
  207. month: "2-digit",
  208. day: "2-digit",
  209. hour: "2-digit",
  210. minute: "2-digit",
  211. second: "2-digit",
  212. hour12: false
  213. })
  214. else if (options.filename_suffix == 'date')
  215. options.filename = options.filename + (new Date()).toLocaleDateString(undefined, {
  216. year: "numeric",
  217. month: "2-digit",
  218. day: "2-digit"
  219. })
  220. }
  221. let anchor = document.createElement('a')
  222. anchor.href = url
  223. anchor.target = '_blank'
  224. anchor.download = `${options.filename}.csv`
  225. anchor.click()
  226. URL.revokeObjectURL(url)
  227. anchor.remove()
  228. }
  229. }
  230. // initialize model
  231. _model.reset()
  232. return _model
  233. }