Skip to content

Commit

Permalink
query builder
Browse files Browse the repository at this point in the history
  • Loading branch information
Skyler Lewis committed Sep 11, 2023
1 parent dd8121f commit 74b6a30
Show file tree
Hide file tree
Showing 3 changed files with 285 additions and 0 deletions.
46 changes: 46 additions & 0 deletions src/queryBuilder/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { it, describe, expect } from "bun:test";
import { RecordQueryBuilder } from ".";
import { sql } from "../sql";

function newQueryBuilder() {
return new RecordQueryBuilder("test");
}

describe("RecordQueryBuilder", () => {
it("constructor() works", async () => {
const qb = newQueryBuilder();
expect(qb.baseTable.alias).toBe("t");
expect(qb.baseTable.tableName).toBe("test");

// add where clauses and check
const one = "1";
const two = "2";
const nqb = newQueryBuilder();
nqb.addWhereClause(sql`a = ${one}`);
nqb.addWhereClause(sql`b = ${two}`);
expect(nqb.compile().render()).toBe(
"SELECT t.* FROM ONLY test AS t WHERE (a = '1') AND (b = '2')"
);

// add having clauses and check
const three = "3";
const four = "4";
const havingQb = newQueryBuilder();
havingQb.addHavingClause(sql`c = ${three}`);
havingQb.addHavingClause(sql`d = ${four}`);
expect(havingQb.compile().render()).toBe(
// TODO: This is bad, because you need a group by to have a having
"SELECT t.* FROM ONLY test AS t HAVING (c = '3') AND (d = '4')"
);

// add order bys and check
const orderByQb = newQueryBuilder();
orderByQb.addOrderBy(sql`e`);
orderByQb.addOrderBy(sql`f`);
expect(orderByQb.compile().render()).toBe(
"SELECT t.* FROM ONLY test AS t ORDER BY e,f"
);

// add group bys and check
});
});
204 changes: 204 additions & 0 deletions src/queryBuilder/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { SQLQuery } from "../sqlQuery";
import { sql } from "../sql";
import { uuid } from "../sqlUtils";

export class BaseTable {
constructor(
public tableName: string | SQLQuery,
public alias: string,
public qb: RecordQueryBuilder,
public joinColumn: string = "id"
) {}
}
/** HELPERS */
type Dictionary<T = any> = Record<string, T>;

export class RecordQueryBuilder {
public baseTable: BaseTable;
public columns: SQLQuery[] = [];
private whereClauses: SQLQuery[] = [];
private havingClauses: SQLQuery[] = [];
private searchClauses: SQLQuery[] = [];
private orderBys: SQLQuery[] = [];
protected withs: Dictionary<SQLQuery> = {};
private groupBy: SQLQuery[] = [];
private joins: SQLQuery[] = [];
private joinedTables: Dictionary<BaseTable> = {};
private joinedTableAliases: Dictionary<BaseTable> = {};
private distincts: SQLQuery[] = [];
public limit: number | undefined;
public withCount: boolean = false;

constructor(baseTable: string) {
this.baseTable = new BaseTable(baseTable, "t", this);
}

addSelectableColumn(column: string | SQLQuery, as?: string) {
let sqlColumn;
if (typeof column === "string") sqlColumn = sql`:${column}`;
else sqlColumn = column;
if (as) sqlColumn = sql`${sqlColumn} AS :${as}`;
if (!this.columns.map((c) => c._repr).includes(sqlColumn._repr)) {
this.columns.push(sqlColumn);
}
}

addWhereClause(clause: SQLQuery) {
this.whereClauses.push(clause);
}

addHavingClause(clause: SQLQuery) {
this.havingClauses.push(clause);
}

addWithClause(name: string, clause: SQLQuery) {
if (!(name in this.withs)) {
this.withs[name] = clause;
}
}

addDistinctColumn(column: string | SQLQuery) {
let query = column as SQLQuery;
if (typeof column === "string") {
query = sql`:${column}`;
}
this.distincts.push(query);
}

addSearchTerm(clause: SQLQuery) {
// basically a where clause, but gets "OR"d
this.searchClauses.push(clause);
}

addOrderBy(orderBy: SQLQuery) {
this.orderBys.push(orderBy);
}

addRawJoin(sqlJoinClause: SQLQuery) {
this.joins.push(sqlJoinClause);
}

stealWithsFrom(qb: RecordQueryBuilder) {
this.withs = { ...qb.withs, ...this.withs };
qb.withs = {};
}

joinTables(
tableA: BaseTable,
tableB: BaseTable,
joinColumnOfTableB: string,
useLeft = true
) {
let joinAlias = `${tableA.alias}_${tableB.alias}`;
if (!(joinAlias in this.joinedTables)) {
if (tableB.alias in this.joinedTableAliases) {
tableB.alias = tableB.alias + "_" + uuid("short");
}
this.joinedTableAliases[tableB.alias] = tableB;
this.joinedTables[joinAlias] = tableB;
let style = useLeft ? sql`LEFT` : sql`INNER`;
this.joins.push(
sql`${style} JOIN ONLY :${tableB.tableName} :${tableB.alias} ON :${tableA.alias}.:${tableA.joinColumn} = :${tableB.alias}.:${joinColumnOfTableB}`
);
return tableB;
} else {
return this.joinedTables[joinAlias];
}
}

addGroupBy(groupByClause: SQLQuery) {
if (!this.groupBy.includes(groupByClause)) this.groupBy.push(groupByClause);
}

convertFiltersToSelectableColumn(as?: string) {
let sqlColumn;
if (this.whereClauses?.length == 0 && this.havingClauses?.length == 0) {
sqlColumn = sql`true`;
} else {
let clauses = [...this.whereClauses, ...this.havingClauses];
sqlColumn = sql.merge(
clauses.map((w) => sql`(${w})`),
sql` AND `
);
}
if (as) sqlColumn = sql`${sqlColumn} AS :${as}`;
this.columns.push(sqlColumn);
this.whereClauses = [];
this.havingClauses = [];
}

compile(withBaseAlias = false): SQLQuery {
this.joinedTables["base"] = this.baseTable;
let columns = this.columns?.length ? sql.merge(this.columns) : sql`t.*`;
let withClause = Object.keys(this.withs).length
? sql`WITH ${sql.merge(
Object.keys(this.withs).map(
(n) => sql`:${n} AS MATERIALIZED (${this.withs[n]})`
)
)} `
: sql``;
Object.keys(this.withs).map((n) => {
if (n != this.baseTable.tableName) {
this.joinTables(this.baseTable, new BaseTable(n, n, this), "id");
}
});

let distinctClause = this.distincts.length
? sql`DISTINCT ON (${sql.merge(this.distincts)}) `
: sql``;
let searchClause = this.searchClauses?.length
? sql`${sql.merge(
this.searchClauses.map((s) => sql`(${s})`),
sql` OR `
)}`
: null;
if (searchClause) this.whereClauses.push(searchClause);

let whereClause = this.whereClauses?.length
? sql`WHERE ${sql.merge(
this.whereClauses.map((w) => sql`(${w})`),
sql` AND `
)}`
: sql``;
let havingClause = this.havingClauses?.length
? sql`HAVING ${sql.merge(
this.havingClauses.map((h) => sql`(${h})`),
sql` AND `
)}`
: sql``;
let groupByClause = this.groupBy?.length
? sql`GROUP BY ${sql.merge(this.groupBy)}`
: sql``;
let sortClause = this.orderBys?.length
? sql`ORDER BY ${sql.merge(this.orderBys)}`
: sql``;
let limitClause = this.limit ? sql`LIMIT ${this.limit}` : sql``;
if (this.withCount) {
columns = sql`${columns}, count(*) OVER() AS record_count`;
}
let joinClause = this.joins?.length ? sql.merge(this.joins, sql` `) : sql``;

let tableClause =
this.baseTable.tableName instanceof SQLQuery
? this.baseTable.tableName
: sql`ONLY :${this.baseTable.tableName}`;
let alias = withBaseAlias ? sql`:${this.baseTable.alias}` : sql`t`;

const clauses = [
joinClause,
whereClause,
groupByClause,
havingClause,
sortClause,
limitClause,
];
const nonEmptyClauses = clauses.filter(
(clause) => clause._repr.trim() !== ""
);

return sql`${withClause}SELECT ${distinctClause}${columns} FROM ${tableClause} AS ${alias} ${sql.merge(
nonEmptyClauses,
sql` `
)}`;
}
}
35 changes: 35 additions & 0 deletions src/sqlUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,38 @@ export function update(
[id, ...real_values],
];
}

export const randStr = (length: number = 10) => {
let id = "";
for (let i = 0; i < Math.ceil(length / 10); i++) {
id += rand_string();
}
return id.substring(0, length);
};

export const sequential_id = () => {
const now = Date.now();
return now.toString(36);
};

export const uuid = (size: "short" | "long" = "long", prefix = ""): string => {
let random_string = "";
const length = size === "short" ? 8 : 16;
for (let i = prefix.length; i < length; i++) {
random_string += randChar();
}
const nonPrefixId = random_string.substring(0, length - prefix.length);
if (
(prefix.length === 0 || prefix[prefix.length - 1] === ".") &&
/\d/.test(nonPrefixId[0])
) {
return uuid(size, prefix);
}
const shortenedId = prefix + nonPrefixId;
return shortenedId;
};

export const randChar = () =>
"abcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(Math.random() * 36)];

const rand_string = () => Math.random().toString(36).slice(2);

0 comments on commit 74b6a30

Please sign in to comment.