HATEOAS-first backend framework.
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.

269 regels
9.2 KiB

  1. import { Resource, validation as v, BaseResourceType } from '@modal-sh/yasumi';
  2. import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend';
  3. import { Database } from 'duckdb-async';
  4. import assert from 'assert';
  5. type ID = number;
  6. interface DuckDbDataSourceBase<
  7. ID,
  8. Schema extends v.BaseSchema = v.BaseSchema,
  9. CurrentName extends string = string,
  10. CurrentRouteName extends string = string,
  11. Data extends object = v.Output<Schema>,
  12. > extends DataSource<Data, ID> {
  13. resource?: Resource<BaseResourceType & {
  14. schema: Schema,
  15. name: CurrentName,
  16. routeName: CurrentRouteName,
  17. }>;
  18. db?: Database;
  19. }
  20. export const AutoincrementIdConfig = {
  21. // TODO add options: https://duckdb.org/docs/sql/statements/create_sequence
  22. generationStrategy: async (dataSourceRaw: DataSource) => {
  23. const dataSource = dataSourceRaw as DuckDbDataSourceBase<ID>;
  24. assert(typeof dataSource.db !== 'undefined');
  25. assert(typeof dataSource.resource !== 'undefined');
  26. const idAttr = dataSource.resource.state.shared.get('idAttr');
  27. assert(typeof idAttr === 'string');
  28. const con = await dataSource.db.connect();
  29. const stmt = await con.prepare(`
  30. SELECT nextval('${dataSource.resource.state.routeName}_sequence') as ${idAttr};
  31. `);
  32. const [v] = await stmt.all();
  33. return v[idAttr];
  34. },
  35. schema: v.number(),
  36. serialize: (v: unknown) => v?.toString() ?? '',
  37. deserialize: (v: string) => Number(v),
  38. }
  39. export class DuckDbDataSource<
  40. Schema extends v.BaseSchema = v.BaseSchema,
  41. CurrentName extends string = string,
  42. CurrentRouteName extends string = string,
  43. Data extends object = v.Output<Schema>,
  44. > implements DuckDbDataSourceBase<ID, Schema, CurrentName, CurrentRouteName, Data> {
  45. resource?: Resource<BaseResourceType & {
  46. schema: Schema,
  47. name: CurrentName,
  48. routeName: CurrentRouteName,
  49. }>;
  50. db?: Database;
  51. constructor(private readonly path: string) {
  52. // noop
  53. }
  54. async initialize() {
  55. assert(typeof this.path !== 'undefined');
  56. assert(typeof this.resource !== 'undefined');
  57. const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
  58. assert(typeof idConfig !== 'undefined');
  59. const idAttr = this.resource.state.shared.get('idAttr');
  60. assert(typeof idAttr === 'string');
  61. const idSchema = idConfig.schema as v.BaseSchema;
  62. this.db = await Database.create(this.path);
  63. const clause = `CREATE TABLE IF NOT EXISTS ${this.resource.state.routeName}`;
  64. const resourceSchema = this.resource.schema as unknown as v.ObjectSchema<any>;
  65. const tableSchema = Object.entries(resourceSchema.entries)
  66. .map(([columnName, columnDefRaw]) => {
  67. const columnDef = columnDefRaw as unknown as v.BaseSchema;
  68. return [columnName, columnDef.type].join(' ');
  69. })
  70. .join(',');
  71. let sequenceSql = '';
  72. let defaultValue = '';
  73. let idType = 'STRING';
  74. if (idSchema.type === 'number') {
  75. // TODO support more sequence statements: https://duckdb.org/docs/sql/statements/create_sequence
  76. sequenceSql = `CREATE SEQUENCE IF NOT EXISTS ${this.resource.state.routeName}_sequence START 1;`;
  77. defaultValue = `DEFAULT nextval('${this.resource.state.routeName}_sequence')`;
  78. idType = 'INTEGER';
  79. }
  80. const sql = `${sequenceSql}${clause} (${idAttr} ${idType} ${defaultValue},${tableSchema});`;
  81. const con = await this.db.connect();
  82. const stmt = await con.prepare(sql);
  83. await stmt.run();
  84. }
  85. prepareResource<Schema extends v.BaseSchema>(resource: Resource<BaseResourceType & {
  86. schema: Schema,
  87. name: CurrentName,
  88. routeName: CurrentRouteName,
  89. }>) {
  90. resource.dataSource = resource.dataSource ?? this;
  91. const originalResourceId = resource.id;
  92. resource.id = <NewIdAttr extends BaseResourceType['idAttr'], NewIdSchema extends BaseResourceType['idSchema']>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => {
  93. originalResourceId(newIdAttr, params);
  94. return resource as Resource<BaseResourceType & {
  95. name: CurrentName,
  96. routeName: CurrentRouteName,
  97. schema: Schema,
  98. idAttr: NewIdAttr,
  99. idSchema: NewIdSchema,
  100. }>;
  101. };
  102. this.resource = resource as any;
  103. }
  104. async getMultiple(query) {
  105. // TODO translate query to SQL statements
  106. assert(typeof this.db !== 'undefined');
  107. assert(typeof this.resource !== 'undefined');
  108. const con = await this.db.connect();
  109. const stmt = await con.prepare(`
  110. SELECT * FROM ${this.resource.state.routeName};
  111. `);
  112. const data = await stmt.all();
  113. return data as Data[];
  114. }
  115. async newId() {
  116. assert(typeof this.resource !== 'undefined');
  117. const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any>;
  118. assert(typeof idConfig !== 'undefined');
  119. const theNewId = await idConfig.generationStrategy(this);
  120. return theNewId as ID;
  121. }
  122. async create(data: Data) {
  123. assert(typeof this.db !== 'undefined');
  124. assert(typeof this.resource !== 'undefined');
  125. const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined;
  126. assert(typeof idConfig !== 'undefined');
  127. const idAttr = this.resource.state.shared.get('idAttr');
  128. assert(typeof idAttr === 'string');
  129. let theId: any;
  130. const { [idAttr]: dataId } = data as Record<string, unknown>;
  131. if (typeof dataId !== 'undefined') {
  132. theId = idConfig.deserialize((data as Record<string, string>)[idAttr]);
  133. } else {
  134. const newId = await this.newId();
  135. theId = idConfig.deserialize(newId.toString());
  136. }
  137. const effectiveData = {
  138. ...data,
  139. } as Record<string, unknown>;
  140. effectiveData[idAttr] = theId;
  141. const clause = `INSERT INTO ${this.resource.state.routeName}`;
  142. const keys = Object.keys(effectiveData).join(',');
  143. const values = Object.values(effectiveData).map((d) => JSON.stringify(d).replace(/"/g, "'")).join(',');
  144. const sql = `${clause} (${keys}) VALUES (${values});`;
  145. const con = await this.db.connect();
  146. const stmt = await con.prepare(sql);
  147. await stmt.run();
  148. const newData = {
  149. ...effectiveData
  150. };
  151. return newData as Data;
  152. }
  153. async getTotalCount(query) {
  154. assert(typeof this.db !== 'undefined');
  155. assert(typeof this.resource !== 'undefined');
  156. const idAttr = this.resource.state.shared.get('idAttr');
  157. assert(typeof idAttr === 'string');
  158. const con = await this.db.connect();
  159. const stmt = await con.prepare(`
  160. SELECT COUNT(*) as c FROM ${this.resource.state.routeName};
  161. `);
  162. const [data] = await stmt.all();
  163. return data['c'] as unknown as number;
  164. }
  165. async getById(id: ID) {
  166. assert(typeof this.db !== 'undefined');
  167. assert(typeof this.resource !== 'undefined');
  168. const idAttr = this.resource.state.shared.get('idAttr');
  169. assert(typeof idAttr === 'string');
  170. const con = await this.db.connect();
  171. const stmt = await con.prepare(`
  172. SELECT * FROM ${this.resource.state.routeName} WHERE ${idAttr} = ${JSON.stringify(id).replace(/"/g, "'")};
  173. `);
  174. const [data = null] = await stmt.all();
  175. return data as Data | null;
  176. }
  177. async getSingle(query) {
  178. assert(typeof this.db !== 'undefined');
  179. assert(typeof this.resource !== 'undefined');
  180. const con = await this.db.connect();
  181. const stmt = await con.prepare(`
  182. SELECT * FROM ${this.resource.state.routeName} LIMIT 1;
  183. `);
  184. const [data = null] = await stmt.all();
  185. return data as Data | null;
  186. }
  187. async delete(id: ID) {
  188. assert(typeof this.db !== 'undefined');
  189. assert(typeof this.resource !== 'undefined');
  190. const idAttr = this.resource.state.shared.get('idAttr');
  191. assert(typeof idAttr === 'string');
  192. const con = await this.db.connect();
  193. const stmt = await con.prepare(`
  194. DELETE FROM ${this.resource.state.routeName} WHERE ${idAttr} = ${JSON.stringify(id).replace(/"/g, "'")};
  195. `);
  196. await stmt.run();
  197. }
  198. async emplace(id: ID, data: Data) {
  199. assert(typeof this.db !== 'undefined');
  200. assert(typeof this.resource !== 'undefined');
  201. const idAttr = this.resource.state.shared.get('idAttr');
  202. assert(typeof idAttr === 'string');
  203. const clause = `INSERT OR REPLACE INTO ${this.resource.state.routeName}`;
  204. const keys = Object.keys(data).join(',');
  205. const values = Object.values(data).map((d) => JSON.stringify(d).replace(/"/g, "'")).join(',');
  206. const sql = `${clause} (${idAttr},${keys}) VALUES (${id},${values});`;
  207. const con = await this.db.connect();
  208. const stmt = await con.prepare(sql);
  209. const [newData] = await stmt.all();
  210. // TODO check if created flag
  211. return [newData, false] as [Data, boolean];
  212. }
  213. async patch(id: ID, data: Partial<Data>) {
  214. assert(typeof this.db !== 'undefined');
  215. assert(typeof this.resource !== 'undefined');
  216. const idAttr = this.resource.state.shared.get('idAttr');
  217. assert(typeof idAttr === 'string');
  218. const clause = `UPDATE ${this.resource.state.routeName}`;
  219. const setParams = Object.entries(data).map(([key, value]) => (
  220. `${key} = ${JSON.stringify(value).replace(/"/g, "'")}`
  221. )).join(',');
  222. const sql = `${clause} SET ${setParams} WHERE ${idAttr} = ${JSON.stringify(id).replace(/"/g, "'")}`
  223. const con = await this.db.connect();
  224. const stmt = await con.prepare(sql);
  225. const [newData] = await stmt.all();
  226. return newData as Data;
  227. }
  228. }