Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

264 строки
7.2 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 _xhr = null
  46. let _promise = null
  47. // construct the model
  48. let _model = {
  49. // reflection methods
  50. configs() { return _configs },
  51. cache() { return _cache },
  52. data(offset=0, limit=Infinity) { return _cache.data && _cache.data.slice(offset, offset+limit) || [] },
  53. ambient_changed() {
  54. return _cache.ambient != _cache.last_ambient
  55. },
  56. reset() {
  57. let ambient_queries = [
  58. ...(_configs.selects ? _configs.selects : []),
  59. ...(_configs.wheres ? _configs.wheres.map(where => ({label: where.label, op: where.op, value: typeof where.value == 'function' ? where.value() : where.value})) : []),
  60. ...(_configs.order ? _configs.order : [])
  61. ]
  62. _cache.last_ambient = _cache.ambient
  63. _cache.ambient =JSON.stringify(ambient_queries)
  64. if (this.ambient_changed()) {
  65. _cache.data = []
  66. _cache.count = null
  67. _cache.upstream_limit = null
  68. _xhr = null
  69. //this.fully_loaded = false
  70. }
  71. },
  72. // main methods
  73. select(offset=0, limit=Infinity) {
  74. // normalize limit
  75. if (limit == Infinity)
  76. limit = _cache.upstream_limit || _cache.count || Infinity
  77. // be lazy 1: if the data is presented, return the value immediately
  78. if (this.data(offset, limit).length > 0 && !this.data(offset, limit).includes(undefined))
  79. return Promise.resolve(this.data(offset, limit))
  80. // be lazy 2: if there is a promise, return it if ambient matches or
  81. // cancel it
  82. if (_promise != null)
  83. return _promise
  84. // TODO
  85. // ambient = select + order + limit/offset
  86. // if ambient is changed, cancel currently loading xhr; otherwise return
  87. // current promise
  88. // now the hard work
  89. _promise = _configs.api.request({
  90. method: 'GET',
  91. url: _configs.endpoint,
  92. headers: _cache.count == null ? {
  93. Prefer: 'count=exact'
  94. } : {},
  95. config: xhr => _xhr = xhr,
  96. queries: [
  97. // transform model state to postgest queries
  98. // selects
  99. ...(_configs.selects ? [{
  100. label: 'select',
  101. value: _configs.selects.map(select => select.alias ? select.alias + ':' + select.label : select.label).join(',')
  102. }] : []),
  103. ...(_configs.wheres ? _configs.wheres : []),
  104. // order
  105. ...(_configs.order ? [{
  106. label: 'order',
  107. value: _configs.order
  108. .map(o => [
  109. o.label,
  110. o.direction,
  111. o.nulls ? 'nulls'+o.nulls : ''
  112. ].filter(a => a).join('.'))
  113. .join(',')
  114. }] : []),
  115. // limit/offset
  116. offset == 0 ? undefined : {label: 'offset', value: offset},
  117. limit == Infinity ? undefined : {label: 'limit', value: limit}
  118. ]
  119. }).then(response => {
  120. // gather begin/end/count
  121. let [_range, _count] = _xhr.getResponseHeader('content-range').split('/')
  122. let [_begin, _end] = _range.split('-').map(v => ~~v)
  123. // update count if presented
  124. if (_count != '*') _cache.count = _count
  125. // see if an upstream limit is exposed
  126. if (_end - _begin + 1 < limit) _cache.upstream_limit = _end - _begin + 1
  127. // fill the data cache
  128. response.forEach((data, i) => {
  129. // process values
  130. _configs.selects && _configs.selects
  131. .filter(select => select.processor)
  132. .forEach(select => data[select.alias || select.label] = select.processor(data[select.alias || select.label]))
  133. // save the data
  134. _cache.data[_begin + i] = data
  135. })
  136. // assert offset/limit and returned range
  137. if (offset != _begin || _end - _begin + 1 > limit)
  138. throw 'The request and response data range mismatches!'
  139. if (_end - _begin + 1 < limit)
  140. console.warn('The response range is narrower than requested, probably due to an upstream hard limit.')
  141. // clean model state
  142. _promise = null
  143. this.reset()
  144. // return data
  145. return _cache.data.slice(_begin, _end + 1)
  146. })
  147. return _promise
  148. },
  149. select_all() {
  150. // be lazy 1: if the data is presented, return the value immediately
  151. if (this.data().length == _cache.count && !this.data().includes(undefined))
  152. return Promise.resolve(this.data())
  153. // be lazy 2: if there is a promise, return it if ambient matches or
  154. // cancel it
  155. if (_promise != null)
  156. return _promise
  157. _promise = _configs.api.request({
  158. method: 'POST',
  159. url: '/rpc/select_all',
  160. body: {
  161. endpoint: _configs.endpoint,
  162. selects: _configs.selects || [],
  163. wheres: _configs.wheres ? _configs.wheres.map(where => ({label: where.label, op: where.op, value: typeof where.value == 'function' ? where.value() : where.value})) : [],
  164. order: _configs.order
  165. }
  166. }).then(response => {
  167. _cache.count = response.length
  168. _cache.data = response.map(data => {
  169. // process values
  170. _configs.selects && _configs.selects
  171. .filter(select => select.processor)
  172. .forEach(select => data[select.alias || select.label] = select.processor(data[select.alias || select.label]))
  173. return data
  174. })
  175. // clean state
  176. _promise = null
  177. this.reset()
  178. })
  179. return _promise
  180. },
  181. export(options={}) {
  182. let _data = this.data()
  183. if (options.type == 'csv') {
  184. let headers = _configs.selects && _configs.selects.map(select => select.alias || select.label) || Object.keys(_data[0])
  185. let body = _data.map(row => headers.map(key => row[key]).join(',')).join('\n')
  186. _data = '\ufeff' + headers.join(',') + '\n' + body
  187. options.type = 'text/csv'
  188. }
  189. let blob = new Blob([_data], {type: options.type})
  190. let url = URL.createObjectURL(blob)
  191. options.filename = options.filename || _configs.endpoint
  192. if (options.filename_suffix) {
  193. if (options.filename_suffix == 'datetime')
  194. options.filename = options.filename + (new Date()).toLocaleTimeString(undefined, {
  195. year: "numeric",
  196. month: "2-digit",
  197. day: "2-digit",
  198. hour: "2-digit",
  199. minute: "2-digit",
  200. second: "2-digit",
  201. hour12: false
  202. })
  203. else if (options.filename_suffix == 'date')
  204. options.filename = options.filename + (new Date()).toLocaleDateString(undefined, {
  205. year: "numeric",
  206. month: "2-digit",
  207. day: "2-digit"
  208. })
  209. }
  210. let anchor = document.createElement('a')
  211. anchor.href = url
  212. anchor.target = '_blank'
  213. anchor.download = `${options.filename}.csv`
  214. anchor.click()
  215. URL.revokeObjectURL(url)
  216. anchor.remove()
  217. }
  218. }
  219. // initialize model
  220. _model.reset()
  221. return _model
  222. }