diff --git a/app/__test__/data/relations.test.ts b/app/__test__/data/relations.test.ts index c62decb..19eab85 100644 --- a/app/__test__/data/relations.test.ts +++ b/app/__test__/data/relations.test.ts @@ -106,7 +106,6 @@ describe("Relations", async () => { expect(postAuthorRel?.other(posts).entity).toBe(users); const kysely = em.connection.kysely; - const jsonFrom = (e) => e; /** * Relation Helper */ @@ -123,7 +122,7 @@ describe("Relations", async () => { .selectFrom(users.name) .select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts")); expect(selectPostsFromUsers.compile().sql).toBe( - 'select (select "posts"."id" as "id", "posts"."title" as "title", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as "posts" from "users"' + 'select (select from "posts" as "posts" where "posts"."author_id" = "users"."id") as "posts" from "users"' ); expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField); const userObj = { id: 1, username: "test" }; @@ -143,7 +142,7 @@ describe("Relations", async () => { .select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author")); expect(selectUsersFromPosts.compile().sql).toBe( - 'select (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"' + 'select (select from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"' ); expect(postAuthorRel.getField()).toBeInstanceOf(RelationField); const postObj = { id: 1, title: "test" }; diff --git a/app/__test__/data/specs/WithBuilder.spec.ts b/app/__test__/data/specs/WithBuilder.spec.ts index b8f70ec..bed48a6 100644 --- a/app/__test__/data/specs/WithBuilder.spec.ts +++ b/app/__test__/data/specs/WithBuilder.spec.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect, test } from "bun:test"; +import { _jsonp } from "../../../src/core/utils"; import { Entity, EntityManager, @@ -9,12 +10,13 @@ import { WithBuilder } from "../../../src/data"; import * as proto from "../../../src/data/prototype"; +import { compileQb, prettyPrintQb } from "../../helper"; import { getDummyConnection } from "../helper"; -const { dummyConnection, afterAllCleanup } = getDummyConnection(); -afterAll(afterAllCleanup); +const { dummyConnection } = getDummyConnection(); function schemaToEm(s: ReturnType<(typeof proto)["em"]>): EntityManager { + const { dummyConnection } = getDummyConnection(); return new EntityManager(Object.values(s.entities), dummyConnection, s.relations, s.indices); } @@ -77,9 +79,9 @@ describe("[data] WithBuilder", async () => { const res = qb.compile(); expect(res.sql).toBe( - 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as agg) as "posts" from "users"' + 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" order by "posts"."id" asc limit ? offset ?) as agg) as "posts" from "users"' ); - expect(res.parameters).toEqual([5]); + expect(res.parameters).toEqual([10, 0]); const qb2 = WithBuilder.addClause( em, @@ -93,9 +95,9 @@ describe("[data] WithBuilder", async () => { const res2 = qb2.compile(); expect(res2.sql).toBe( - 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as obj) as "author" from "posts"' + 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"' ); - expect(res2.parameters).toEqual([1]); + expect(res2.parameters).toEqual([1, 0]); }); test("test with empty join", async () => { @@ -165,8 +167,8 @@ describe("[data] WithBuilder", async () => { id: 2, label: "beauty", posts: [ - { id: 2, title: "beauty post" }, - { id: 1, title: "fashion post" } + { id: 1, title: "fashion post" }, + { id: 2, title: "beauty post" } ] }, { @@ -198,9 +200,9 @@ describe("[data] WithBuilder", async () => { ); const res = qb.compile(); expect(res.sql).toBe( - 'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as obj) as "single" from "categories"' + 'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"' ); - expect(res.parameters).toEqual(["categories.single", 1]); + expect(res.parameters).toEqual(["categories.single", 1, 0]); const qb2 = WithBuilder.addClause( em, @@ -210,9 +212,9 @@ describe("[data] WithBuilder", async () => { ); const res2 = qb2.compile(); expect(res2.sql).toBe( - 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as agg) as "multiple" from "categories"' + 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"' ); - expect(res2.parameters).toEqual(["categories.multiple", 5]); + expect(res2.parameters).toEqual(["categories.multiple", 10, 0]); }); /*test("test manytoone", async () => { @@ -236,4 +238,205 @@ describe("[data] WithBuilder", async () => { const res = await em.repository().findMany("posts", { join: ["author"] }); console.log(res.sql, res.parameters, res.result); });*/ + + describe("recursive", () => { + test("compiles with singles", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", {}), + users: proto.entity("users", { + username: proto.text() + }), + media: proto.entity("media", { + path: proto.text() + }) + }, + ({ relation }, { posts, users, media }) => { + relation(posts).manyToOne(users); + relation(users).polyToOne(media, { mappedBy: "avatar" }); + } + ); + const em = schemaToEm(schema); + + const qb = WithBuilder.addClause( + em, + em.connection.kysely.selectFrom("posts"), + schema.entities.posts, + { + users: { + limit: 5, // ignored + select: ["id", "username"], + sort: { by: "username", dir: "asc" }, + with: { + avatar: { + select: ["id", "path"], + limit: 2 // ignored + } + } + } + } + ); + + //prettyPrintQb(qb); + expect(qb.compile().sql).toBe( + 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"' + ); + expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]); + }); + + test("compiles with many", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", {}), + comments: proto.entity("comments", {}), + users: proto.entity("users", { + username: proto.text() + }), + media: proto.entity("media", { + path: proto.text() + }) + }, + ({ relation }, { posts, comments, users, media }) => { + relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" }); + relation(users).polyToOne(media, { mappedBy: "avatar" }); + relation(comments).manyToOne(posts).manyToOne(users); + } + ); + const em = schemaToEm(schema); + + const qb = WithBuilder.addClause( + em, + em.connection.kysely.selectFrom("posts"), + schema.entities.posts, + { + comments: { + limit: 12, + with: { + users: { + select: ["username"] + } + } + } + } + ); + + expect(qb.compile().sql).toBe( + 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"' + ); + expect(qb.compile().parameters).toEqual([1, 0, 12, 0]); + }); + + test("returns correct result", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", { + title: proto.text() + }), + comments: proto.entity("comments", { + content: proto.text() + }), + users: proto.entity("users", { + username: proto.text() + }), + media: proto.entity("media", { + path: proto.text() + }) + }, + ({ relation }, { posts, comments, users, media }) => { + relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" }); + relation(users).polyToOne(media, { mappedBy: "avatar" }); + relation(comments).manyToOne(posts).manyToOne(users); + } + ); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + // add data + await em.mutator("users").insertMany([{ username: "user1" }, { username: "user2" }]); + await em.mutator("posts").insertMany([ + { title: "post1", users_id: 1 }, + { title: "post2", users_id: 1 }, + { title: "post3", users_id: 2 } + ]); + await em.mutator("comments").insertMany([ + { content: "comment1", posts_id: 1, users_id: 1 }, + { content: "comment1-1", posts_id: 1, users_id: 1 }, + { content: "comment2", posts_id: 1, users_id: 2 }, + { content: "comment3", posts_id: 2, users_id: 1 }, + { content: "comment4", posts_id: 2, users_id: 2 }, + { content: "comment5", posts_id: 3, users_id: 1 }, + { content: "comment6", posts_id: 3, users_id: 2 } + ]); + + const result = await em.repo("posts").findMany({ + select: ["title"], + with: { + comments: { + limit: 2, + select: ["content"], + with: { + users: { + select: ["username"] + } + } + } + } + }); + + expect(result.data).toEqual([ + { + title: "post1", + comments: [ + { + content: "comment1", + users: { + username: "user1" + } + }, + { + content: "comment1-1", + users: { + username: "user1" + } + } + ] + }, + { + title: "post2", + comments: [ + { + content: "comment3", + users: { + username: "user1" + } + }, + { + content: "comment4", + users: { + username: "user2" + } + } + ] + }, + { + title: "post3", + comments: [ + { + content: "comment5", + users: { + username: "user1" + } + }, + { + content: "comment6", + users: { + username: "user2" + } + } + ] + } + ]); + //console.log(_jsonp(result.data)); + }); + }); }); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index e11da33..de6993e 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -1,6 +1,7 @@ import { unlink } from "node:fs/promises"; -import type { SqliteDatabase } from "kysely"; +import type { SelectQueryBuilder, SqliteDatabase } from "kysely"; import Database from "libsql"; +import { format as sqlFormat } from "sql-formatter"; import { SqliteLocalConnection } from "../src/data"; export function getDummyDatabase(memory: boolean = true): { @@ -51,3 +52,13 @@ export function enableConsoleLog() { console[severity as ConsoleSeverity] = fn; }); } + +export function compileQb(qb: SelectQueryBuilder) { + const { sql, parameters } = qb.compile(); + return { sql, parameters }; +} + +export function prettyPrintQb(qb: SelectQueryBuilder) { + const { sql, parameters } = qb.compile(); + console.log("$", sqlFormat(sql), "\n[params]", parameters); +} diff --git a/app/package.json b/app/package.json index 038d7cd..d05eccc 100644 --- a/app/package.json +++ b/app/package.json @@ -74,6 +74,7 @@ "react-hook-form": "^7.53.1", "react-icons": "5.2.1", "react-json-view-lite": "^2.0.1", + "sql-formatter": "^15.4.9", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.14", "tailwindcss-animate": "^1.0.7", diff --git a/app/src/data/connection/SqliteLocalConnection.ts b/app/src/data/connection/SqliteLocalConnection.ts index 0b1a8c8..b3bfab3 100644 --- a/app/src/data/connection/SqliteLocalConnection.ts +++ b/app/src/data/connection/SqliteLocalConnection.ts @@ -1,6 +1,5 @@ -import type { DatabaseIntrospector, SqliteDatabase } from "kysely"; +import { type DatabaseIntrospector, ParseJSONResultsPlugin, type SqliteDatabase } from "kysely"; import { Kysely, SqliteDialect } from "kysely"; -import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin"; import { SqliteConnection } from "./SqliteConnection"; import { SqliteIntrospector } from "./SqliteIntrospector"; @@ -14,7 +13,7 @@ class CustomSqliteDialect extends SqliteDialect { export class SqliteLocalConnection extends SqliteConnection { constructor(private database: SqliteDatabase) { - const plugins = [new DeserializeJsonValuesPlugin()]; + const plugins = [new ParseJSONResultsPlugin()]; const kysely = new Kysely({ dialect: new CustomSqliteDialect({ database }), plugins diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 638b99b..5234bc4 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -65,7 +65,7 @@ export class Repository): RepoQuery { + getValidOptions(options?: Partial): RepoQuery { const entity = this.entity; // @todo: if not cloned deep, it will keep references and error if multiple requests come in const validated = { @@ -228,43 +228,79 @@ export class Repository, - exclude_options: (keyof RepoQuery)[] = [] - ): { qb: RepositoryQB; options: RepoQuery } { + config?: { + validate?: boolean; + ignore?: (keyof RepoQuery)[]; + alias?: string; + defaults?: Pick; + } + ) { const entity = this.entity; - const options = this.getValidOptions(_options); + let qb = _qb ?? (this.conn.selectFrom(entity.name) as RepositoryQB); - const alias = entity.name; + const options = config?.validate !== false ? this.getValidOptions(_options) : _options; + if (!options) return qb; + + const alias = config?.alias ?? entity.name; const aliased = (field: string) => `${alias}.${field}`; - let qb = this.conn - .selectFrom(entity.name) - .select(entity.getAliasedSelectFrom(options.select, alias)); + const ignore = config?.ignore ?? []; + const defaults = { + limit: 10, + offset: 0, + ...config?.defaults + }; - //console.log("build query options", options); - if (!exclude_options.includes("with") && options.with) { + /*console.log("build query options", { + entity: entity.name, + options, + config + });*/ + + if (!ignore.includes("select") && options.select) { + qb = qb.select(entity.getAliasedSelectFrom(options.select, alias)); + } + + if (!ignore.includes("with") && options.with) { qb = WithBuilder.addClause(this.em, qb, entity, options.with); } - if (!exclude_options.includes("join") && options.join) { + if (!ignore.includes("join") && options.join) { qb = JoinBuilder.addClause(this.em, qb, entity, options.join); } // add where if present - if (!exclude_options.includes("where") && options.where) { + if (!ignore.includes("where") && options.where) { qb = WhereBuilder.addClause(qb, options.where); } - if (!exclude_options.includes("limit")) qb = qb.limit(options.limit); - if (!exclude_options.includes("offset")) qb = qb.offset(options.offset); + if (!ignore.includes("limit")) qb = qb.limit(options.limit ?? defaults.limit); + if (!ignore.includes("offset")) qb = qb.offset(options.offset ?? defaults.offset); // sorting - if (!exclude_options.includes("sort")) { - qb = qb.orderBy(aliased(options.sort.by), options.sort.dir); + if (!ignore.includes("sort")) { + qb = qb.orderBy(aliased(options.sort?.by ?? "id"), options.sort?.dir ?? "asc"); } - //console.log("options", { _options, options, exclude_options }); - return { qb, options }; + return qb as RepositoryQB; + } + + private buildQuery( + _options?: Partial, + ignore: (keyof RepoQuery)[] = [] + ): { qb: RepositoryQB; options: RepoQuery } { + const entity = this.entity; + const options = this.getValidOptions(_options); + + return { + qb: this.addOptionsToQueryBuilder(undefined, options, { + ignore, + alias: entity.name + }), + options + }; } async findId( diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 1d5cfe5..ce4f14c 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -1,38 +1,9 @@ import { isObject } from "core/utils"; import type { KyselyJsonFrom, RepoQuery } from "data"; import { InvalidSearchParamsException } from "data/errors"; -import type { RepoWithSchema } from "data/server/data-query-impl"; import type { Entity, EntityManager, RepositoryQB } from "../../entities"; export class WithBuilder { - /*private static buildClause( - em: EntityManager, - qb: RepositoryQB, - entity: Entity, - ref: string, - config?: RepoQuery - ) { - const relation = em.relationOf(entity.name, withString); - if (!relation) { - throw new Error(`Relation "${withString}" not found`); - } - - const cardinality = relation.ref(withString).cardinality; - //console.log("with--builder", { entity: entity.name, withString, cardinality }); - - const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom; - - if (!jsonFrom) { - throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom"); - } - - try { - return relation.buildWith(entity, qb, jsonFrom, withString); - } catch (e) { - throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`); - } - }*/ - static addClause( em: EntityManager, qb: RepositoryQB, @@ -59,11 +30,23 @@ export class WithBuilder { throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom"); } - const alias = relation.other(entity).reference; + const other = relation.other(entity); newQb = newQb.select((eb) => { - return jsonFrom(relation.buildWith(entity, ref)(eb)).as(alias); + let subQuery = relation.buildWith(entity, ref)(eb); + if (query) { + subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, { + ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter( + Boolean + ) as any + }); + } + + if (query.with) { + subQuery = WithBuilder.addClause(em, subQuery, other.entity, query.with as any); + } + + return jsonFrom(subQuery).as(other.reference); }); - //newQb = relation.buildWith(entity, qb, jsonFrom, ref); } return newQb; @@ -72,7 +55,7 @@ export class WithBuilder { static validateWiths(em: EntityManager, entity: string, withs: RepoQuery["with"]) { let depth = 0; if (!withs || !isObject(withs)) { - console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); + withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); return depth; } diff --git a/app/src/data/relations/ManyToOneRelation.ts b/app/src/data/relations/ManyToOneRelation.ts index e95ea06..de53ad1 100644 --- a/app/src/data/relations/ManyToOneRelation.ts +++ b/app/src/data/relations/ManyToOneRelation.ts @@ -158,28 +158,12 @@ export class ManyToOneRelation extends EntityRelation) => eb .selectFrom(`${self.entity.name} as ${relationRef}`) - .select(self.entity.getSelect(relationRef)) .whereRef(entityRef, "=", otherRef) - .limit(limit); - - /*return qb.select((eb) => - jsonFrom( - eb - .selectFrom(`${self.entity.name} as ${relationRef}`) - .select(self.entity.getSelect(relationRef)) - .whereRef(entityRef, "=", otherRef) - .limit(limit) - ).as(relationRef) - );*/ + .$if(self.cardinality === 1, (qb) => qb.limit(1)); } /** diff --git a/app/src/data/relations/PolymorphicRelation.ts b/app/src/data/relations/PolymorphicRelation.ts index fa30974..cf77108 100644 --- a/app/src/data/relations/PolymorphicRelation.ts +++ b/app/src/data/relations/PolymorphicRelation.ts @@ -90,26 +90,13 @@ export class PolymorphicRelation extends EntityRelation) => eb .selectFrom(other.entity.name) - .select(other.entity.getSelect(other.entity.name)) .where(whereLhs, "=", reference) .whereRef(entityRef, "=", otherRef) - .limit(limit); - - /*return qb.select((eb) => - jsonFrom( - eb - .selectFrom(other.entity.name) - .select(other.entity.getSelect(other.entity.name)) - .where(whereLhs, "=", reference) - .whereRef(entityRef, "=", otherRef) - .limit(limit) - ).as(other.reference) - );*/ + .$if(other.cardinality === 1, (qb) => qb.limit(1)); } override isListableFor(entity: Entity): boolean { diff --git a/bun.lockb b/bun.lockb index 82c57ef..19836ad 100755 Binary files a/bun.lockb and b/bun.lockb differ