import { getKysely } from "./kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; import type { DocumentAccessor } from "../model/doc"; import type { Document, QueryListOption, DocumentBody } from "dbtype/api"; import type { NotNull } from "kysely"; import { MyParseJSONResultsPlugin } from "./plugin"; export type DBTagContentRelation = { doc_id: number; tag_name: string; }; class SqliteDocumentAccessor implements DocumentAccessor { constructor(private kysely = getKysely()) { } async search(search_word: string): Promise { throw new Error("Method not implemented."); } async addList(content_list: DocumentBody[]): Promise { return await this.kysely.transaction().execute(async (trx) => { // add tags const tagCollected = new Set(); for (const content of content_list) { for (const tag of content.tags) { tagCollected.add(tag); } } await trx.insertInto("tags") .values(Array.from(tagCollected).map((x) => ({ name: x }))) .onConflict((oc) => oc.doNothing()) .execute(); const ids = await trx.insertInto("document") .values(content_list.map((content) => { const { tags, additional, ...rest } = content; return { additional: JSON.stringify(additional), created_at: Date.now(), ...rest, }; })) .returning("id") .execute(); const id_lst = ids.map((x) => x.id); const doc_tags = content_list.flatMap((content, index) => { const { tags, ...rest } = content; return tags.map((tag) => ({ doc_id: id_lst[index], tag_name: tag })); }); await trx.insertInto("doc_tag_relation") .values(doc_tags) .execute(); return id_lst; }); } async add(c: DocumentBody) { return await this.kysely.transaction().execute(async (trx) => { const { tags, additional, ...rest } = c; const id_lst = await trx.insertInto("document").values({ additional: JSON.stringify(additional), created_at: Date.now(), ...rest, }) .returning("id") .executeTakeFirst() as { id: number }; const id = id_lst.id; // add tags await trx.insertInto("tags") .values(tags.map((x) => ({ name: x }))) // on conflict is supported in sqlite and postgresql. .onConflict((oc) => oc.doNothing()) .execute(); if (tags.length > 0) { await trx.insertInto("doc_tag_relation") .values(tags.map((x) => ({ doc_id: id, tag_name: x }))) .execute(); } return id; }); } async del(id: number) { // delete tags await this.kysely .deleteFrom("doc_tag_relation") .where("doc_id", "=", id) .execute(); // delete document const result = await this.kysely .deleteFrom("document") .where("id", "=", id) .executeTakeFirst(); return result.numDeletedRows > 0; } async findById(id: number, tagload?: boolean): Promise { const doc = await this.kysely.selectFrom("document") .selectAll() .where("id", "=", id) .$if(tagload ?? false, (qb) => qb.select(eb => jsonArrayFrom( eb.selectFrom("doc_tag_relation") .select(["doc_tag_relation.tag_name"]) .whereRef("document.id", "=", "doc_tag_relation.doc_id") .select("tag_name") ).as("tags")).withPlugin(new MyParseJSONResultsPlugin("tags")) ) .executeTakeFirst(); if (!doc) return undefined; return { ...doc, content_hash: doc.content_hash ?? "", additional: doc.additional !== null ? JSON.parse(doc.additional) : {}, tags: doc.tags?.map((x: { tag_name: string }) => x.tag_name) ?? [], }; } async findDeleted(content_type: string) { const docs = await this.kysely .selectFrom("document") .selectAll() .where("content_type", "=", content_type) .where("deleted_at", "is not", null) .$narrowType<{ deleted_at: NotNull }>() .execute(); return docs.map((x) => ({ ...x, tags: [], content_hash: x.content_hash ?? "", additional: {}, })); } async findList(option?: QueryListOption) { const { allow_tag = [], eager_loading = true, limit = 20, use_offset = false, offset = 0, word, content_type, cursor, } = option ?? {}; const result = await this.kysely .selectFrom("document") .selectAll() .$if(allow_tag.length > 0, (qb) => { return allow_tag.reduce((prevQb, tag, index) => { return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.doc_id`, "document.id") .where(`tags_${index}.tag_name`, "=", tag); }, qb) as unknown as typeof qb; }) .$if(word !== undefined, (qb) => qb.where("title", "like", `%${word}%`)) .$if(content_type !== undefined, (qb) => qb.where("content_type", "=", content_type as string)) .$if(use_offset, (qb) => qb.offset(offset)) .$if(!use_offset && cursor !== undefined, (qb) => qb.where("id", "<", cursor as number)) .limit(limit) .$if(eager_loading, (qb) => { return qb.select(eb => eb.selectFrom(e => e.selectFrom("doc_tag_relation") .select(["doc_tag_relation.tag_name"]) .whereRef("document.id", "=", "doc_tag_relation.doc_id") .as("agg") ).select(e => e.fn.agg("json_group_array", ["agg.tag_name"]) .as("tags_list") ).as("tags") ) }) .orderBy("id", "desc") .execute(); return result.map((x) => ({ ...x, content_hash: x.content_hash ?? "", additional: x.additional !== null ? (JSON.parse(x.additional)) : {}, tags: JSON.parse(x.tags ?? "[]"), //?.map((x: { tag_name: string }) => x.tag_name) ?? [], })); } async findByPath(path: string, filename?: string): Promise { const results = await this.kysely .selectFrom("document") .selectAll() .where("basepath", "=", path) .$if(filename !== undefined, (qb) => qb.where("filename", "=", filename as string)) .execute(); return results.map((x) => ({ ...x, content_hash: x.content_hash ?? "", tags: [], additional: {}, })); } async update(c: Partial & { id: number }) { const { id, tags, additional, ...rest } = c; const r = await this.kysely.updateTable("document") .set({ ...rest, modified_at: Date.now(), additional: additional !== undefined ? JSON.stringify(additional) : undefined, }) .where("id", "=", id) .executeTakeFirst(); return r.numUpdatedRows > 0; } async addTag(c: Document, tag_name: string) { if (c.tags.includes(tag_name)) return false; await this.kysely.insertInto("tags") .values({ name: tag_name }) .onConflict((oc) => oc.doNothing()) .execute(); await this.kysely.insertInto("doc_tag_relation") .values({ tag_name: tag_name, doc_id: c.id }) .execute(); c.tags.push(tag_name); return true; } async delTag(c: Document, tag_name: string) { if (c.tags.includes(tag_name)) return false; await this.kysely.deleteFrom("doc_tag_relation") .where("tag_name", "=", tag_name) .where("doc_id", "=", c.id) .execute(); c.tags.splice(c.tags.indexOf(tag_name), 1); return true; } } export const createSqliteDocumentAccessor = (kysely = getKysely()): DocumentAccessor => { return new SqliteDocumentAccessor(kysely); };