ionian/packages/server/src/db/doc.ts
2024-04-13 01:31:46 +09:00

234 lines
6.9 KiB
TypeScript

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<Document[]> {
throw new Error("Method not implemented.");
}
async addList(content_list: DocumentBody[]): Promise<number[]> {
return await this.kysely.transaction().execute(async (trx) => {
// add tags
const tagCollected = new Set<string>();
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<Document | undefined> {
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<string>("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<Document[]> {
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<Document> & { 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);
};