diff --git a/README.md b/README.md index 86eaee3..2938499 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,320 @@ > Type safe query builder for SQLite -## Usage +## Installation -Take a look at the test files for some examples. +This package is published on [JSR](https://jsr.io/@dldc/zendb): -Feel free to open an issue if you have any questions ! +```bash +# npm +npx jsr add @dldc/zendb +# deno +deno add @dldc/zendb +``` + +## Driver library + +In addition to this package, you will need to install a driver library for +SQLite. There are 3 currently available: + +- `@dldc/zendb-db-sqlite` for [`@db/sqlite`](https://jsr.io/@db/sqlite) + available on [JSR](https://jsr.io/@dldc/zendb-db-sqlite) +- `@dldc/zendb-sqljs` for [`sql.js`](https://www.npmjs.com/package/sql.js) + available on [NPM](https://www.npmjs.com/package/@dldc/zendb-sqljs) +- `@dldc/zendb-better-sqlite3` for + [`better-sqlite3`](https://www.npmjs.com/package/better-sqlite3) available on + [NPM](https://www.npmjs.com/package/@dldc/zendb-better-sqlite3) + +Those librairiers are quite simple, if your driver is not listed here, you can +easily create your own driver by looking at the source code of the existing +ones. + +## Overview + +Here is an overview of how to use this library: + +### 1. Create a schema + +```ts +import { Column, Table } from "@dldc/zendb"; + +export const schema = Table.declareMany({ + tasks: { + id: Column.text().primary(), + title: Column.text(), + description: Column.text(), + completed: Column.boolean(), + }, + users: { + id: Column.text().primary(), + name: Column.text(), + email: Column.text(), + displayName: Column.text().nullable(), + groupId: Column.text(), + updatedAt: Column.date().nullable(), + }, + joinUsersTasks: { + user_id: Column.text().primary(), + task_id: Column.text().primary(), + }, + groups: { + id: Column.text().primary(), + name: Column.text(), + }, +}); +``` + +### 2. Initialize the database + +```ts +import { Database } from "@db/sqlite"; +import { DbDatabase } from "@dldc/zendb-db-sqlite"; + +// create @db/sqlite database +const sqlDb = new Database(dbPath); +// pass it to the adapter +const db = DbDatabase(sqlDb); + +// the you probably want to create the tables if they don't exist +// this library does not provide a proper migration system +// this is a simple way to create the tables if they don't exist +import { Database as ZenDatabase } from "@dldc/zendb"; + +// get the list of tables +const tables = db.exec(ZenDatabase.tables()); +if (tables.length === 0) { + // create the tables + db.execMany( + ZenDatabase.schema(schema, { ifNotExists: true, strict: true }), + ); +} +``` + +### 3. Run queries + +Your `db` variable (created from the driver library) will expose at least 2 +methods: + +- `exec(DatabaseOperation)` +- `execMany(DatabaseOperation[])` + +You will then use your schema to create `DatabaseOperation` objects: + +```ts +import { schema } from "./schema.ts"; + +const userQueryOp = schema.users.query().andFilterEqual({ id: "my-id" }) + .maybeOne(); +const result = db.exec(userQueryOp); +// ^^^^^^ this is type safe 🎉 +``` + +## `insert`, `update`, `delete` + +They are 6 methods available on the `Table` object: + +- `.insert(item)` +- `.insertMany(item[])` +- `.delete(condtion)` (`condition` is an expression function, detailed below) +- `.deleteEqual(filters)` this is a shortcut for `.delete()` with simple + equality filters +- `.update(item, condition)` (`condition` is an expression function, detailed + below) +- `.updateEqual(item, filters)` this is a shortcut for `.update()` with simple + equality filters + +## Queries + +To create a query, you need to call the `.query()` method on a table. This will +return a `Query` object. You can then chain methods to build your query. + +There are 2 kind of methods on the `Query` object: + +- Methods that return an database operation (`.all()`, `.one()`, `.maybeOne()`, + `.first()`, `.maybeFirst()`). You must always end your query with one of those + methods. +- Methods that return a new query object with updated properties (all the other + methods). + +Here is an example: + +```ts +const query = schema.tasks.query() + .andFilterEqual({ completed: false }) + .all(); + +const tasks = db.exec(query); +``` + +## Query end methods + +- `.all()`: return all the rows that match the query +- `.maybeFirst()`: add a `LIMIT 1` to the query and return the first row or + `null` +- `.first()`: similar to `.maybeFirst()` but will throw an error if no row is + found +- `.maybeOne()`: return the first row or `null` if no row is found, will throw + an error if more than one row is found +- `.one()`: similar to `.maybeOne()` but will throw an error if no row is found + +_Note: `.first()` and `.maybeFirst()` will override any `LIMIT` that was set on +the query._ + +## Expressions + +One key concept in this library is its ability to create and manipulate type +safe [expressions](https://www.sqlite.org/lang_expr.html). If you don't already +know what an expression is, you can think of it as any piece of SQL that can be +evaluated to a value. This includes: + +- A column name (`id`, `task.name`) +- A literal value (`"hello"`, `42`) +- A function (`COUNT(*)`, `SUM(task.duration)`) +- A binary operation (`1 + 2`, `task.duration * 60`) +- A variable (`:myVar`) + +### `Expr.external` + +The `Expr.external` function is used to create an expression from a variable. + +```ts +import { Expr } from "@dldc/zendb"; + +const query2 = schema.tasks.query() + .limit(Expr.external(10)) + .all(); + +console.log(query2.sql); // SELECT tasks.* FROM tasks LIMIT :_hgJnoKSYKp +console.log(query2.params); // { _hgJnoKSYKp: 10 } +``` + +As you can see, the `Expr.external` function create a variable with a unique +name (`_hgJnoKSYKp`) that will be send to the driver. This is important because +it protects you from SQL injection ! + +### Expression functions + +Many methods on the `Query` object take an expression function as argument. This +is a function that will be called with an object that contains the columns of +the table, each column is an expression so you can use them to build your +expression. + +Here is an example with the `.where` method: + +```ts +const meOrYou = schema.users.query() + .where((c) => + Expr.or( + Expr.equal(c.id, Expr.external("me")), + Expr.equal(c.id, Expr.external("you")), + ) + ) + .all(); +``` + +### Select function + +The `.select()` method is used to select specific columns. It takes an +expression function as parameters that should return an object with the columns +you want to select, each columns can be any expression. + +_Note: if you don't provide a select(), all columns from the source tables are +selected_ + +```ts +// select only some properties of the user +const userQuery = schema.users.query() + .select((c) => ({ id: c.id, name: c.name })) + .all(); +// SELECT users.id AS id, users.name AS name FROM users + +// Using destructuring +const userQuery = schema.users.query() + .select(({ id, name }) => ({ id, name })) + .all(); +// SELECT users.id AS id, users.name AS name FROM users + +// Using expressions +const userQueryConcat = schema.users.query() + .select((c) => ({ id: c.id, name: Expr.concatenate(c.name, c.email) })) + .all(); +// SELECT users.id AS id, users.name || users.email AS name FROM users + +// Without select, all columns are selected +const userQueryAll = schema.users.query().all(); +// SELECT users.* FROM users +``` + +## Joins + +You can join tables using the `.innerJoin()` and `.leftJoin()` method. Those two +have the same signature and take the following arguments: + +- `table` The first argument is the Query object. Most of the time the result of + a `.query()` call but it can be any query object. If needed, the library will + use a CTE to make the join. +- `alias` The second argument is a string that will be used as an alias for the + table. This will be used to reference the columns of the table in the + expression function. +- `joinOn` The third argument is an expression function that should return a + boolean expression that will be used to join the tables. + +Here is an example: + +```ts +const usersWithGroups = schema.users.query() + .innerJoin( + schema.groups.query(), + "groupAlias", + (c) => Expr.equal(c.groupId, c.groupAlias.id), + ) + .select((c) => ({ + id: c.id, + name: c.name, + groupName: c.groupAlias.name, // Notice the .groupAlias here + })) + .all(); +``` + +The resulting query will look like this: + +```sql +SELECT users.id AS id, + users.name AS name, + t_8vUvrgUNne.name AS groupName +FROM users + INNER JOIN groups AS t_8vUvrgUNne ON users.groupId == t_8vUvrgUNne.id +``` + +_Note: the join alias you define is not used in the query itself, instead a +random name is used !_ + +## Using [CTEs](https://en.wikipedia.org/wiki/Hierarchical_and_recursive_queries_in_SQL) + +When you join a table, the library will automatically use a CTE to make the join +if needed. But you might want to use a CTE yourself. You can do this by using +the `queryFrom()` function: + +```ts +import { queryFrom } from "@dldc/zendb"; + +const query1 = schema.users + .query() + .select((cols) => ({ demo: cols.id, id: cols.id })) + .groupBy((cols) => [cols.name]); + +const withCte = queryFrom(query1).all(); +``` + +The resulting query will look like this: + +```sql +WITH cte_id15 AS ( + SELECT users.id AS demo, + users.id AS id + FROM users + GROUP BY users.name +) +SELECT cte_id15.* +FROM cte_id15 +``` diff --git a/deno.json b/deno.json index b12e2c3..4e00834 100644 --- a/deno.json +++ b/deno.json @@ -10,9 +10,11 @@ "dedent": "npm:dedent@^1.5.3", "sql-formatter": "npm:sql-formatter@^15.3.1" }, + "unstable": ["ffi"], "tasks": { - "test:run": "deno test -A --unstable-ffi", - "test:watch": "deno test -A --unstable-ffi --watch", + "test:run": "deno test -A", + "test:watch": "deno test -A --watch", + "test:coverage": "deno test -A --coverage && deno coverage coverage --html", "bump": "deno run -A jsr:@mys/bump@1", "update": "deno run -A jsr:@molt/cli deno.json", "update:commit": "deno task -q update --commit deno.json", diff --git a/experiment.ts b/experiment.ts deleted file mode 100644 index 5e30267..0000000 --- a/experiment.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Database } from "@db/sqlite"; - -const db = new Database(":memory:"); - -const stmt = db.prepare(`SELECT json('{"hey": 42}') as a`); - -const res = stmt.get(); - -console.log(res); diff --git a/src/Query.ts b/src/Query.ts index c1121c8..dbda772 100644 --- a/src/Query.ts +++ b/src/Query.ts @@ -22,7 +22,9 @@ import { PRIV, TYPES } from "./utils/constants.ts"; import { appendDependencies, mergeDependencies } from "./utils/dependencies.ts"; import { mapObject } from "./utils/functions.ts"; import { isStateEmpty } from "./utils/isStateEmpty.ts"; +import { markColumnsNullable } from "./utils/markColumnsNullable.ts"; import { extractParams } from "./utils/params.ts"; +import { remapColumnsTables } from "./utils/remapColumnsTables.ts"; import type { AnyRecord, ExprRecord, @@ -31,7 +33,7 @@ import type { FilterEqualCols, } from "./utils/types.ts"; import { whereEqual } from "./utils/whereEqual.ts"; -import { createNoRows } from "./ZendbErreur.ts"; +import { createNoRows, createTooManyRows } from "./ZendbErreur.ts"; export function queryFromTable( table: Ast.Identifier, @@ -48,12 +50,12 @@ export function queryFromTable( } export function queryFrom< - Table extends ITableQuery, ->(table: Table): ITableQuery { - const internal = table[PRIV]; + Query extends ITableQuery, +>(query: Query): ITableQuery { + const internal = query[PRIV]; if (isStateEmpty(internal.state)) { // if there are no state, there is no need to create a CTE - return table as any; + return query as any; } const colsRef = mapObject(internal.outputColsRefs, (key, col) => { const jsonMode: TJsonMode | undefined = col[PRIV].jsonMode === undefined @@ -95,17 +97,24 @@ function createQuery< [TYPES]: {} as any, where, + andWhere, + andFilterEqual, + groupBy, + andGroupBy, + having, + andHaving, + select, + orderBy, - sortAsc, - sortDesc, + andSortAsc, + andSortDesc, limit, offset, // Shortcuts - filterEqual, // joins innerJoin, @@ -128,6 +137,21 @@ function createQuery< function where( whereFn: ColsFn, + ): ITableQuery { + const result = resolveColFn(whereFn)(internal.inputColsRefs); + const nextDependencies = mergeDependencies( + internal.dependencies, + result[PRIV].dependencies, + ); + return createQuery({ + ...internal, + dependencies: nextDependencies, + state: { ...internal.state, where: result }, + }); + } + + function andWhere( + whereFn: ColsFn, ): ITableQuery { const result = resolveColFn(whereFn)(internal.inputColsRefs); const nextDependencies = mergeDependencies( @@ -149,6 +173,12 @@ function createQuery< }); } + function andFilterEqual( + filters: Partial>, + ): ITableQuery { + return andWhere((cols) => whereEqual(cols, filters)); + } + function groupBy( groupFn: ColsFn>, ): ITableQuery { @@ -156,6 +186,17 @@ function createQuery< return createQuery({ ...internal, state: { ...internal.state, groupBy } }); } + function andGroupBy( + groupFn: ColsFn>, + ): ITableQuery { + const groupBy = resolveColFn(groupFn)(internal.inputColsRefs); + const nextGroupBy = [...(internal.state.groupBy ?? []), ...groupBy]; + return createQuery({ + ...internal, + state: { ...internal.state, groupBy: nextGroupBy }, + }); + } + function having( havingFn: ColsFn, ): ITableQuery { @@ -173,6 +214,31 @@ function createQuery< }); } + function andHaving( + havingFn: ColsFn, + ): ITableQuery { + const having = resolveColFn(havingFn)(internal.inputColsRefs); + if (internal.state.having) { + const havingAnd = Expr.and(internal.state.having, having); + return createQuery({ + ...internal, + dependencies: mergeDependencies( + internal.dependencies, + having[PRIV].dependencies, + ), + state: { ...internal.state, having: havingAnd }, + }); + } + return createQuery({ + ...internal, + dependencies: mergeDependencies( + internal.dependencies, + having[PRIV].dependencies, + ), + state: { ...internal.state, having }, + }); + } + function select( selectFn: SelectFn, ): ITableQuery { @@ -213,37 +279,14 @@ function createQuery< }); } - function appendOrderingExpr( - expr: TExprUnknow, - dir: "Asc" | "Desc", - ): ITableQuery { - const orderingTerm = Ast.createNode("OrderingTerm", { - expr: expr.ast, - direction: dir, - }); - if (!internal.state.orderBy) { - return createQuery({ - ...internal, - state: { ...internal.state, orderBy: [orderingTerm] }, - }); - } - return createQuery({ - ...internal, - state: { - ...internal.state, - orderBy: [...internal.state.orderBy, orderingTerm], - }, - }); - } - - function sortAsc( + function andSortAsc( exprFn: ColsFn, ): ITableQuery { const expr = resolveColFn(exprFn)(internal.inputColsRefs); return appendOrderingExpr(expr, "Asc"); } - function sortDesc( + function andSortDesc( exprFn: ColsFn, ): ITableQuery { const expr = resolveColFn(exprFn)(internal.inputColsRefs); @@ -291,20 +334,18 @@ function createQuery< joinOn: (cols: ColsRefInnerJoined) => TExprUnknow, ): ITableQuery, OutCols> { const tableCte = queryFrom(table); + const tableAlias = builder.Expr.identifier(`t_${Random.createId()}`); const newInColsRef: ColsRefInnerJoined = { ...internal.inputColsRefs, - [alias]: mapObject(tableCte[PRIV].outputColsRefs, (_, col) => { - return { - ...col, - [PRIV]: { ...col[PRIV], nullable: true }, - }; - }), + [alias]: remapColumnsTables(tableCte[PRIV].outputColsRefs, tableAlias), }; const joinItem: builder.SelectStmt.JoinItem = { joinOperator: builder.SelectStmt.InnerJoinOperator(), - tableOrSubquery: builder.SelectStmt.Table(tableCte[PRIV].from.name), + tableOrSubquery: builder.SelectStmt.Table(tableCte[PRIV].from.name, { + alias: tableAlias, + }), joinConstraint: builder.SelectStmt.OnJoinConstraint( joinOn(newInColsRef).ast, ), @@ -326,22 +367,20 @@ function createQuery< joinOn: (cols: ColsRefLeftJoined) => TExprUnknow, ): ITableQuery, OutCols> { const tableCte = queryFrom(table); + const tableAlias = builder.Expr.identifier(`t_${Random.createId()}`); const newInColsRef: ColsRefLeftJoined = { ...internal.inputColsRefs, - [alias]: mapObject( - tableCte[PRIV].outputColsRefs, - // mark all columns as nullable since it's a left join - (_, col: TExprUnknow): TExprUnknow => ({ - ...col, - [PRIV]: { ...col[PRIV], nullable: true }, - }), + [alias]: markColumnsNullable( + remapColumnsTables(tableCte[PRIV].outputColsRefs, tableAlias), ), }; const joinItem: builder.SelectStmt.JoinItem = { joinOperator: builder.SelectStmt.JoinOperator("Left"), - tableOrSubquery: builder.SelectStmt.Table(tableCte[PRIV].from.name), + tableOrSubquery: builder.SelectStmt.Table(tableCte[PRIV].from.name, { + alias: tableAlias, + }), joinConstraint: builder.SelectStmt.OnJoinConstraint( joinOn(newInColsRef).ast, ), @@ -357,47 +396,6 @@ function createQuery< }); } - function filterEqual( - filters: Partial>, - ): ITableQuery { - return where((cols) => whereEqual(cols, filters)); - } - - // function populate, Value>( - // field: Field, - // leftExpr: (cols: InCols) => IExpr, - // table: Table, - // rightKey: (cols: ExprRecordFrom) => IExpr, - // rightExpr: (cols: ExprRecordFrom) => IExpr - // ): ITableQuery { - // const tableGrouped = createCteFrom(table) - // .groupBy((cols) => [rightKey(cols)]) - // .select((cols) => ({ - // key: rightKey(cols), - // value: Expr.AggregateFunctions.json_group_array(rightExpr(cols)), - // })); - // const joinItem: JoinItem = { - // joinOperator: builder.JoinOperator.Join('Left'), - // tableOrSubquery: builder.TableOrSubquery.Table(table[PRIV].from.name), - // joinConstraint: builder.JoinConstraint.On(Expr.equal(leftExpr(internal.inputColsRefs), tableGrouped[PRIV].outputColsRefs.key)), - // }; - // const joined = create({ - // ...internal, - // state: { - // ...internal.state, - // joins: [...(internal.state.joins ?? []), joinItem], - // }, - // parents: mergeParent(internal.parents, tableGrouped[PRIV]), - // }); - - // return joined.select((_cols, prev) => ({ - // ...prev, - // [field]: tableGrouped[PRIV].outputColsRefs.value, - // })) as any; - // } - - // -------------- - function all(): IQueryOperation>> { const node = buildFinalNode(internal); const params = extractParams(node); @@ -418,11 +416,16 @@ function createQuery< } function maybeOne(): IQueryOperation | null> { - const allOp = limit(() => Expr.literal(1)).all(); + // Note: here we could limit to 2 rows to detect if there are too many rows + // But we don't because we want to know how many rows there are in the error + const allOp = all(); return { ...allOp, parse: (rows) => { const res = allOp.parse(rows); + if (res.length > 1) { + throw createTooManyRows(res.length); + } return res.length === 0 ? null : res[0]; }, }; @@ -443,7 +446,7 @@ function createQuery< } function maybeFirst(): IQueryOperation | null> { - const allOp = all(); + const allOp = limit(Expr.literal(1)).all(); return { ...allOp, parse: (rows) => { @@ -466,6 +469,31 @@ function createQuery< }, }; } + + // UTILS + + function appendOrderingExpr( + expr: TExprUnknow, + dir: "Asc" | "Desc", + ): ITableQuery { + const orderingTerm = Ast.createNode("OrderingTerm", { + expr: expr.ast, + direction: dir, + }); + if (!internal.state.orderBy) { + return createQuery({ + ...internal, + state: { ...internal.state, orderBy: [orderingTerm] }, + }); + } + return createQuery({ + ...internal, + state: { + ...internal.state, + orderBy: [...internal.state.orderBy, orderingTerm], + }, + }); + } } function resolveColFn( diff --git a/src/Query.types.ts b/src/Query.types.ts index ef318ec..d3ddf09 100644 --- a/src/Query.types.ts +++ b/src/Query.types.ts @@ -137,79 +137,156 @@ export interface ITableQuery< readonly [TYPES]: OutCols; readonly [PRIV]: ITableQueryInternal; - // - Where /** - * Add conditions to the where clause, you can call it multiple times (AND) + * Set the WHERE clause, this will replace any previous WHERE clause + * @param whereFn An expression function that should return an expression */ where(whereFn: ColsFn): ITableQuery; + + /** + * Add conditions to the WHERE clause using en AND operator + * @param whereFn An expression function that should return an expression + */ + andWhere(whereFn: ColsFn): ITableQuery; + /** - * .where() shortcut to filter on equality + * .andWhere() shortcut to filter on equality, values passed wiil be injected as variables (Expr.external) + * @param filters An object with the columns to filter */ - filterEqual( + andFilterEqual( filters: Prettify>, ): ITableQuery; - // - Group + + /** + * Set the GROUP BY clause, this will replace any previous GROUP BY clause + * @param groupFn An expression function that should return an array of expressions + */ groupBy( groupFn: ColsFn>, ): ITableQuery; - // - Having + + /** + * Add items to GROUP BY clause expression list + * @param groupFn An expression function that should return an array of expressions + */ + andGroupBy( + groupFn: ColsFn>, + ): ITableQuery; + + /** + * Set the HAVING clause, this will replace any previous HAVING clause. + * @param havingFn An expression function that should return an expression + */ having(havingFn: ColsFn): ITableQuery; - // Select + + /** + * Add conditions to the HAVING clause using en AND operator + * @param havingFn An expression function that should return an expression + */ + andHaving( + havingFn: ColsFn, + ): ITableQuery; + + /** + * Set the selected columns, this will replace any previous select clause + * @param selectFn An expression function that should return an object with the columns to select. This expression will receive as a second argument the current selected columns + */ select( selectFn: SelectFn, ): ITableQuery; - // - Order + /** - * Set the order by clause, this will replace any previous order clause + * Set the ORDER BY clause, this will replace any previous order clause. + * For most use cases, you should use `andSortAsc` or `andSortDesc` instead + * @param orderByFn An expression function that should return an array of OrderingTerm objects (also accept the OrderingTerm array directly) */ orderBy( orderByFn: AllColsFnOrRes, ): ITableQuery; + + /** + * Add a `expression ASC` to the existing ORDER BY clause + * @param exprFn An expression function that should return an expression, usually `c => c.myColumn` + */ + andSortAsc(exprFn: ColsFn): ITableQuery; + /** - * Add an order by clause, this will not replace any previous order clause + * Add a `expression DESC` to the existing ORDER BY clause + * @param exprFn An expression function that should return an expression, usually `c => c.myColumn` */ - sortAsc(exprFn: ColsFn): ITableQuery; + andSortDesc( + exprFn: ColsFn, + ): ITableQuery; + /** - * Add an order by clause, this will not replace any previous order clause + * Set the LIMIT clause, this will replace any previous LIMIT clause + * @param limitFn An expression function that should return an expression, usually `Expr.external(myLimit)` */ - sortDesc(exprFn: ColsFn): ITableQuery; - // - Limit / Offset limit( limitFn: AllColsFnOrRes, ): ITableQuery; + + /** + * Set the OFFSET clause, this will replace any previous OFFSET clause + * @param offsetFn An expression function that should return an expression, usually `Expr.external(myOffset)` + */ offset( offsetFn: AllColsFnOrRes, ): ITableQuery; - // Joins + /** + * Create an INNER JOIN with another table. + * @param table A Query object, usually created by `schema.someTable.query()` + * @param alias string that will be used as an alias for the table. This will be used to reference the columns of the table in the expression function. + * @param joinOn An expression function that should return a boolean expression that will be used to join the tables + */ innerJoin, Alias extends string>( table: RTable, alias: Alias, joinOn: (cols: ColsRefInnerJoined) => TExprUnknow, ): ITableQuery, OutCols>; + + /** + * Create a LEFT JOIN with another table. The joined columns will be nullable as the join may not find a match + * @param table A Query object, usually created by `schema.someTable.query()` + * @param alias string that will be used as an alias for the table. This will be used to reference the columns of the table in the expression function. + * @param joinOn An expression function that should return a boolean expression that will be used to join the tables + */ leftJoin, Alias extends string>( table: RTable, alias: Alias, joinOn: (cols: ColsRefLeftJoined) => TExprUnknow, ): ITableQuery, OutCols>; - // shortcut for ease of use - // take(config: number | ITakeConfig): ITableQuery; - // paginate(config: number | IPaginateConfig): ITableQuery; - // groupByCol( - // cols: Array, - // selectFn: ColsFnOrRes - // ): ITableQuery>; - // orderByCol(...cols: Array>): ITableQuery; - - // Returns an Array + /** + * Return an Database operation that will execute the query and return all the results + */ all(): IQueryOperation>>>; - // Throw if result count is > 1 + + /** + * Return an Database operation that will execute the query and return a single result + * If no result is found, will return null + * If more than one result is found, will throw + */ maybeOne(): IQueryOperation> | null>; - // Throw if result count is not === 1 + + /** + * Return an Database operation that will execute the query and return a single result + * If results count is not exactly 1, will throw + */ one(): IQueryOperation>>; - // Never throws + + /** + * Return an Database operation that will execute the query and return the first result + * This will override any limit clause by `LIMIT 1` + * If no result is found, will return null + */ maybeFirst(): IQueryOperation> | null>; - // Throw if result count is === 0 + + /** + * Return an Database operation that will execute the query and return the first result + * This will override any limit clause by `LIMIT 1` + * If no result is found, will throw + */ first(): IQueryOperation>>; } diff --git a/src/ZendbErreur.ts b/src/ZendbErreur.ts index 795c8d5..04410fd 100644 --- a/src/ZendbErreur.ts +++ b/src/ZendbErreur.ts @@ -5,6 +5,7 @@ export type TZendbErreurData = | { kind: "MissingPrimaryKey"; table: string } | { kind: "InvalidUniqueConstraint"; constraintName: string | null } | { kind: "NoRows" } + | { kind: "TooManyRows"; rowsCount: number } | { kind: "ColumnNotFound"; columnKey: string } | { kind: "ColumnDoesNotExist"; column: string } | { kind: "CannotInsertEmptyArray"; table: string }; @@ -54,6 +55,13 @@ export function createNoRows(): Error { ); } +export function createTooManyRows(rowCount: number): Error { + return ZendbErreurInternal.setAndReturn( + new Error(`Expected one row, got ${rowCount}`), + { kind: "TooManyRows", rowsCount: rowCount }, + ); +} + export function createColumnNotFound(columnKey: string): Error { return ZendbErreurInternal.setAndReturn( new Error(`Column not found: ${columnKey}`), diff --git a/src/utils/markColumnsNullable.ts b/src/utils/markColumnsNullable.ts new file mode 100644 index 0000000..e7ea6b8 --- /dev/null +++ b/src/utils/markColumnsNullable.ts @@ -0,0 +1,19 @@ +import type * as Expr from "../expr/Expr.ts"; +import { PRIV } from "./constants.ts"; +import { mapObject } from "./functions.ts"; +import type { ExprRecord, ExprRecord_MakeNullable } from "./types.ts"; + +export function markColumnsNullable( + cols: Cols, +): ExprRecord_MakeNullable { + return mapObject( + cols, + // mark columns as nullable + (_, col: Expr.TExprUnknow): any => { + return ({ + ...col, + [PRIV]: { ...col[PRIV], nullable: true }, + }); + }, + ); +} diff --git a/src/utils/remapColumnsTables.ts b/src/utils/remapColumnsTables.ts new file mode 100644 index 0000000..94530d1 --- /dev/null +++ b/src/utils/remapColumnsTables.ts @@ -0,0 +1,29 @@ +import type { Ast } from "@dldc/sqlite"; +import * as Expr from "../expr/Expr.ts"; +import { PRIV } from "./constants.ts"; +import { mapObject } from "./functions.ts"; +import type { ExprRecord } from "./types.ts"; + +/** + * Change the table of the columns to the new alias + * Expect all expressions to be columns ! + */ +export function remapColumnsTables( + cols: Cols, + tableAlias: Ast.Identifier, +): Cols { + return mapObject( + cols, + // remap columns to the new alias + (_, col: Expr.TExprUnknow): any => { + if (col.ast.kind !== "Column") { + throw new Error("Expected column"); + } + const internal = col[PRIV]; + return Expr.create( + { ...col.ast, table: { name: tableAlias } }, + internal, + ); + }, + ); +} diff --git a/tests/advanced.test.ts b/tests/advanced.test.ts index d687400..56a0843 100644 --- a/tests/advanced.test.ts +++ b/tests/advanced.test.ts @@ -128,18 +128,18 @@ Deno.test("Find all user with their linked tasks", () => { json_group_array( json_object( 'id', - tasks.id, + t_id0.id, 'title', - tasks.title, + t_id0.title, 'description', - tasks.description, + t_id0.description, 'completed', - tasks.completed + t_id0.completed ) ) AS tasks FROM joinUsersTasks - INNER JOIN tasks ON joinUsersTasks.task_id == tasks.id + INNER JOIN tasks AS t_id0 ON joinUsersTasks.task_id == t_id0.id GROUP BY joinUsersTasks.user_id `); @@ -191,24 +191,20 @@ Deno.test("Find all user with their linked tasks", () => { expect(format(query.sql)).toEqual(sql` WITH - cte_id2 AS ( + cte_id3 AS ( SELECT joinUsersTasks.user_id AS userId, json_group_array( json_object( - 'id', - tasks.id, - 'title', - tasks.title, - 'description', - tasks.description, - 'completed', - tasks.completed + 'id', t_id0.id, + 'title', t_id0.title, + 'description', t_id0.description, + 'completed', t_id0.completed ) ) AS tasks FROM joinUsersTasks - INNER JOIN tasks ON joinUsersTasks.task_id == tasks.id + INNER JOIN tasks AS t_id0 ON joinUsersTasks.task_id == t_id0.id GROUP BY joinUsersTasks.user_id ) @@ -219,10 +215,10 @@ Deno.test("Find all user with their linked tasks", () => { users.displayName AS displayName, users.groupId AS groupId, users.updatedAt AS updatedAt, - cte_id2.tasks AS tasks + t_id4.tasks AS tasks FROM users - LEFT JOIN cte_id2 ON users.id == cte_id2.userId + LEFT JOIN cte_id3 AS t_id4 ON users.id == t_id4.userId `); const result = db.exec(query); diff --git a/tests/index.test.ts b/tests/index.test.ts index 6ae6818..473e62e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -285,7 +285,6 @@ Deno.test("Query CTE", () => { SELECT cte_id2.demo AS demo2, cte_id2.id AS id FROM cte_id2 WHERE cte_id2.id == 2 - LIMIT 1 `); expect(result.params).toEqual(null); }); @@ -322,13 +321,13 @@ Deno.test("Query with json", () => { SELECT joinUsersTasks.user_id AS userId, json_object( - 'id', tasks.id, - 'title', tasks.title, - 'description', tasks.description, - 'completed', tasks.completed + 'id', t_id0.id, + 'title', t_id0.title, + 'description', t_id0.description, + 'completed', t_id0.completed ) AS task FROM joinUsersTasks - INNER JOIN tasks ON joinUsersTasks.task_id == tasks.id + INNER JOIN tasks AS t_id0 ON joinUsersTasks.task_id == t_id0.id `); }); diff --git a/tests/join.test.ts b/tests/join.test.ts index 4039323..a3b4bb0 100644 --- a/tests/join.test.ts +++ b/tests/join.test.ts @@ -32,10 +32,9 @@ Deno.test("Query innerJoin", () => { SELECT users.id AS id, users.email AS email, - joinUsersTasks.task_id AS taskId - FROM - users - INNER JOIN joinUsersTasks ON joinUsersTasks.user_id == users.id + t_id0.task_id AS taskId + FROM users + INNER JOIN joinUsersTasks AS t_id0 ON t_id0.user_id == users.id `); }); @@ -65,11 +64,10 @@ Deno.test("Query joins", () => { SELECT users.id AS id, users.email AS email, - tasks.title AS taskName - FROM - users - INNER JOIN joinUsersTasks ON joinUsersTasks.user_id == users.id - INNER JOIN tasks ON tasks.id == joinUsersTasks.task_id + t_id2.title AS taskName + FROM users + INNER JOIN joinUsersTasks AS t_id0 ON t_id0.user_id == users.id + INNER JOIN tasks AS t_id2 ON t_id2.id == t_id0.task_id `); }); @@ -94,22 +92,83 @@ Deno.test("Join on aggregate should use CTE", () => { })).all(); expect(format(groupsWithUsersCount.sql)).toEqual(sql` - WITH - cte_id1 AS ( - SELECT - users.groupId AS groupId, - count(users.id) AS usersCount - FROM - users - GROUP BY - users.groupId - ) + WITH cte_id1 AS ( + SELECT + users.groupId AS groupId, + count(users.id) AS usersCount + FROM users + GROUP BY users.groupId + ) SELECT groups.id AS id, groups.name AS name, - cte_id1.usersCount AS usersCount - FROM - groups - INNER JOIN cte_id1 ON groups.id == cte_id1.groupId + t_id2.usersCount AS usersCount + FROM groups + INNER JOIN cte_id1 AS t_id2 ON groups.id == t_id2.groupId + `); +}); + +Deno.test("Join on table with select should use CTE", () => { + setup(); + + const usersWithSelect = tasksDb.users.query() + .select(({ id, email }) => ({ id, userEmail: email })); + + const groupsWithUsersCount = tasksDb.groups.query().innerJoin( + usersWithSelect, + "users", + (c) => Expr.equal(c.id, c.users.userEmail), + ).select(({ id, name, users }) => ({ + id, + name, + userEmail: users.userEmail, + })).all(); + + expect(format(groupsWithUsersCount.sql)).toEqual(sql` + WITH cte_id0 AS ( + SELECT + users.id AS id, + users.email AS userEmail + FROM users + ) + SELECT + groups.id AS id, + groups.name AS name, + t_id1.userEmail AS userEmail + FROM groups + INNER JOIN cte_id0 AS t_id1 ON groups.id == t_id1.userEmail + `); +}); + +Deno.test("Joining the same table twice should work", () => { + setup(); + + const result = tasksDb.users.query() + .innerJoin( + tasksDb.joinUsersTasks.query(), + "usersTasks", + (cols) => Expr.equal(cols.usersTasks.user_id, cols.id), + ) + .innerJoin( + tasksDb.joinUsersTasks.query(), + "usersTasks2", + (cols) => Expr.equal(cols.usersTasks2.user_id, cols.id), + ) + .select((cols) => ({ + id: cols.id, + email: cols.email, + taskId: cols.usersTasks.task_id, + taskId2: cols.usersTasks2.task_id, + })) + .all(); + + expect(format(result.sql)).toEqual(sql` + SELECT users.id AS id, + users.email AS email, + t_id0.task_id AS taskId, + t_id2.task_id AS taskId2 + FROM users + INNER JOIN joinUsersTasks AS t_id0 ON t_id0.user_id == users.id + INNER JOIN joinUsersTasks AS t_id2 ON t_id2.user_id == users.id `); }); diff --git a/tests/readme.test.ts b/tests/readme.test.ts new file mode 100644 index 0000000..05fb879 --- /dev/null +++ b/tests/readme.test.ts @@ -0,0 +1,155 @@ +import { + Column, + Database as ZenDatabase, + Expr, + queryFrom, + Random, + Table, +} from "@dldc/zendb"; +import { expect } from "@std/expect"; +import { TestDatabase } from "./utils/TestDatabase.ts"; +import { format, sql } from "./utils/sql.ts"; + +Deno.test("Run code from README example", () => { + let nextRandomId = 0; + // disable random suffix for testing + Random.setCreateId(() => `id${nextRandomId++}`); + + const schema = Table.declareMany({ + tasks: { + id: Column.text().primary(), + title: Column.text(), + description: Column.text(), + completed: Column.boolean(), + }, + users: { + id: Column.text().primary(), + name: Column.text(), + email: Column.text(), + displayName: Column.text().nullable(), + groupId: Column.text(), + updatedAt: Column.date().nullable(), + }, + joinUsersTasks: { + user_id: Column.text().primary(), + task_id: Column.text().primary(), + }, + groups: { + id: Column.text().primary(), + name: Column.text(), + }, + }); + + const db = TestDatabase.create(); + + const tables = db.exec(ZenDatabase.tables()); + if (tables.length === 0) { + // create the tables + db.execMany( + ZenDatabase.schema(schema, { ifNotExists: true, strict: true }), + ); + } + + const userQueryOp = schema.users.query().andFilterEqual({ id: "my-id" }) + .maybeOne(); + const result = db.exec(userQueryOp); + expect(result).toEqual(null); + + const query = schema.tasks.query() + .andFilterEqual({ completed: false }) + .all(); + const tasks = db.exec(query); + expect(tasks).toEqual([]); + + // External + + const query2 = schema.tasks.query() + .limit(Expr.external(10)) + .all(); + + expect(query2).toMatchObject({ + kind: "Query", + params: { _id4: 10 }, + sql: "SELECT tasks.* FROM tasks LIMIT :_id4", + }); + + // Expression functions + + const meOrYou = schema.users.query() + .where((c) => + Expr.or( + Expr.equal(c.id, Expr.external("me")), + Expr.equal(c.id, Expr.external("you")), + ) + ) + .maybeOne(); + + const res = db.exec(meOrYou); + expect(res).toEqual(null); + + // .select() + + const userQuery = schema.users.query() + .select((c) => ({ + id: c.id, + name: c.name, + })) + .all(); + expect(userQuery.sql).toEqual( + `SELECT users.id AS id, users.name AS name FROM users`, + ); + + const userQueryConcat = schema.users.query() + .select((c) => ({ id: c.id, name: Expr.concatenate(c.name, c.email) })) + .all(); + expect(userQueryConcat.sql).toEqual( + `SELECT users.id AS id, users.name || users.email AS name FROM users`, + ); + + const userQueryAll = schema.users.query().all(); + expect(userQueryAll.sql).toEqual(`SELECT users.* FROM users`); + + // Join + + const usersWithGroups = schema.users.query() + .innerJoin( + schema.groups.query(), + "groupAlias", + (c) => Expr.equal(c.groupId, c.groupAlias.id), + ) + .select((c) => ({ + id: c.id, + name: c.name, + groupName: c.groupAlias.name, // Notice the .groupAlias here + })) + .all(); + + expect(format(usersWithGroups.sql)).toEqual(sql` + SELECT users.id AS id, + users.name AS name, + t_id11.name AS groupName + FROM users + INNER JOIN groups AS t_id11 ON users.groupId == t_id11.id + `); + + // CTEs + + const query1 = schema.users + .query() + .select((cols) => ({ demo: cols.id, id: cols.id })) + .groupBy((cols) => [cols.name]); + + const withCte = queryFrom(query1).all(); + + console.log(withCte.sql); + expect(format(withCte.sql)).toEqual(sql` + WITH cte_id15 AS ( + SELECT users.id AS demo, + users.id AS id + FROM users + GROUP BY users.name + ) + SELECT cte_id15.* + FROM cte_id15 + `); +}); diff --git a/tests/tasks.test.ts b/tests/tasks.test.ts index 7a4e681..38af82f 100644 --- a/tests/tasks.test.ts +++ b/tests/tasks.test.ts @@ -198,7 +198,7 @@ Deno.test("Find user by email using filterEqual", () => { setup(); const res = db.exec( - tasksDb.users.query().filterEqual({ email: "john@example.com" }).first(), + tasksDb.users.query().andFilterEqual({ email: "john@example.com" }).first(), ); expect(res).toEqual({ id: "1", @@ -271,31 +271,40 @@ Deno.test("Find task by user email", () => { task: Expr.jsonObj(cols.task), })); - const query = tasksWithUser.filterEqual({ "user.email": "john@example.com" }) - .first(); + const query = tasksWithUser.andFilterEqual({ + "user.email": "john@example.com", + }) + .one(); expect(format(query.sql)).toEqual(sql` - SELECT - json_object( - 'id', users.id, - 'name', users.name, - 'email', users.email, - 'displayName', users.displayName, - 'groupId', users.groupId, - 'updatedAt', users.updatedAt + SELECT json_object( + 'id', + t_id2.id, + 'name', + t_id2.name, + 'email', + t_id2.email, + 'displayName', + t_id2.displayName, + 'groupId', + t_id2.groupId, + 'updatedAt', + t_id2.updatedAt ) AS user, json_object( - 'id', tasks.id, - 'title', tasks.title, - 'description', tasks.description, - 'completed', tasks.completed + 'id', + t_id0.id, + 'title', + t_id0.title, + 'description', + t_id0.description, + 'completed', + t_id0.completed ) AS task - FROM - joinUsersTasks - LEFT JOIN tasks ON joinUsersTasks.task_id == tasks.id - LEFT JOIN users ON joinUsersTasks.user_id == users.id - WHERE - users.email == :_id3 + FROM joinUsersTasks + LEFT JOIN tasks AS t_id0 ON joinUsersTasks.task_id == t_id0.id + LEFT JOIN users AS t_id2 ON joinUsersTasks.user_id == t_id2.id + WHERE t_id2.email == :_id5 `); const res = db.exec(query); diff --git a/tests/where.test.ts b/tests/where.test.ts index d5347e2..4994014 100644 --- a/tests/where.test.ts +++ b/tests/where.test.ts @@ -14,7 +14,7 @@ function setup() { Deno.test("Simple filter", () => { setup(); - const query = tasksDb.tasks.query().filterEqual({ id: "1" }).first(); + const query = tasksDb.tasks.query().andFilterEqual({ id: "1" }).one(); expect(format(query.sql)).toEqual(sql` SELECT tasks.* @@ -27,9 +27,10 @@ Deno.test("Simple filter", () => { Deno.test("Filter twice", () => { setup(); - const query = tasksDb.tasks.query().filterEqual({ id: "1" }).filterEqual({ - id: "2", - }).first(); + const query = tasksDb.tasks.query().andFilterEqual({ id: "1" }) + .andFilterEqual({ + id: "2", + }).one(); expect(format(query.sql)).toEqual(sql` SELECT tasks.* @@ -59,41 +60,40 @@ Deno.test("Find task by user email", () => { task: Expr.jsonObj(cols.task), })); - const query = tasksWithUser.filterEqual({ "user.email": "john@example.com" }) - .first(); + const query = tasksWithUser.andFilterEqual({ + "user.email": "john@example.com", + }) + .one(); expect(format(query.sql)).toEqual(sql` - SELECT - json_object( - 'id', users.id, - 'name', users.name, - 'email', users.email, - 'displayName', users.displayName, - 'groupId', users.groupId, - 'updatedAt', users.updatedAt + SELECT json_object( + 'id', t_id2.id, + 'name', t_id2.name, + 'email', t_id2.email, + 'displayName', t_id2.displayName, + 'groupId', t_id2.groupId, + 'updatedAt', t_id2.updatedAt ) AS user, json_object( - 'id', tasks.id, - 'title', tasks.title, - 'description', tasks.description, - 'completed', tasks.completed + 'id', t_id0.id, + 'title', t_id0.title, + 'description', t_id0.description, + 'completed', t_id0.completed ) AS task - FROM - joinUsersTasks - LEFT JOIN tasks ON joinUsersTasks.task_id == tasks.id - LEFT JOIN users ON joinUsersTasks.user_id == users.id - WHERE - users.email == :_id3 + FROM joinUsersTasks + LEFT JOIN tasks AS t_id0 ON joinUsersTasks.task_id == t_id0.id + LEFT JOIN users AS t_id2 ON joinUsersTasks.user_id == t_id2.id + WHERE t_id2.email == :_id5 `); - expect(query.params).toEqual({ _id3: "john@example.com" }); + expect(query.params).toEqual({ _id5: "john@example.com" }); }); Deno.test("Filter null value", () => { setup(); - const query = tasksDb.users.query().filterEqual({ displayName: null }) - .first(); + const query = tasksDb.users.query().andFilterEqual({ displayName: null }) + .one(); expect(format(query.sql)).toEqual(sql` SELECT users.* @@ -105,10 +105,10 @@ Deno.test("Filter null value", () => { Deno.test("Filter multiple values", () => { setup(); - const query = tasksDb.users.query().filterEqual({ + const query = tasksDb.users.query().andFilterEqual({ displayName: null, email: "john@example.com", - }).first(); + }).one(); expect(format(query.sql)).toEqual(sql` SELECT users.*