@teamkeel/functions-runtime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ const { applyWhereConditions } = require("./applyWhereConditions");
2
+ const {
3
+ applyLimit,
4
+ applyOffset,
5
+ applyOrderBy,
6
+ } = require("./applyAdditionalQueryConstraints");
7
+ const { applyJoins } = require("./applyJoins");
8
+ const { camelCaseObject } = require("./casing");
9
+ const { useDatabase } = require("./database");
10
+ const { QueryContext } = require("./QueryContext");
11
+ const tracing = require("./tracing");
12
+
13
+ class QueryBuilder {
14
+ /**
15
+ * @param {string} tableName
16
+ * @param {import("./QueryContext").QueryContext} context
17
+ * @param {import("kysely").Kysely} db
18
+ */
19
+ constructor(tableName, context, db) {
20
+ this._tableName = tableName;
21
+ this._context = context;
22
+ this._db = db;
23
+ }
24
+
25
+ where(where) {
26
+ const context = this._context.clone();
27
+
28
+ let builder = applyJoins(context, this._db, where);
29
+ builder = applyWhereConditions(context, builder, where);
30
+
31
+ return new QueryBuilder(this._tableName, context, builder);
32
+ }
33
+
34
+ orWhere(where) {
35
+ const context = this._context.clone();
36
+
37
+ let builder = applyJoins(context, this._db, where);
38
+
39
+ builder = builder.orWhere((qb) => {
40
+ return applyWhereConditions(context, qb, where);
41
+ });
42
+
43
+ return new QueryBuilder(this._tableName, context, builder);
44
+ }
45
+
46
+ async findMany(params) {
47
+ const name = tracing.spanNameForModelAPI(this._modelName, "findMany");
48
+ const db = useDatabase();
49
+
50
+ return tracing.withSpan(name, async (span) => {
51
+ const context = new QueryContext([this._tableName], this._tableConfigMap);
52
+
53
+ let builder = db
54
+ .selectFrom((qb) => {
55
+ // this._db contains all of the where constraints and joins
56
+ // we want to include that in the sub query in the same way we
57
+ // add all of this information into the sub query in the ModelAPI's
58
+ // implementation of findMany
59
+ return this._db.as(this._tableName);
60
+ })
61
+ .selectAll();
62
+
63
+ // The only constraints added to the main query are the orderBy, limit and offset as they are performed on the "outer" set
64
+ if (params?.limit) {
65
+ builder = applyLimit(context, builder, params.limit);
66
+ }
67
+
68
+ if (params?.offset) {
69
+ builder = applyOffset(context, builder, params.offset);
70
+ }
71
+
72
+ if (
73
+ params?.orderBy !== undefined &&
74
+ Object.keys(params?.orderBy).length > 0
75
+ ) {
76
+ builder = applyOrderBy(
77
+ context,
78
+ builder,
79
+ this._tableName,
80
+ params.orderBy
81
+ );
82
+ } else {
83
+ builder = builder.orderBy(`${this._tableName}.id`);
84
+ }
85
+
86
+ const query = builder;
87
+
88
+ span.setAttribute("sql", query.compile().sql);
89
+ const rows = await builder.execute();
90
+ return rows.map((x) => camelCaseObject(x));
91
+ });
92
+ }
93
+ }
94
+
95
+ module.exports.QueryBuilder = QueryBuilder;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * QueryContext is used to store state about the current query, for example
3
+ * which joins have already been applied. It is used by applyJoins and
4
+ * applyWhereConditions to generate consistent table aliases for joins.
5
+ *
6
+ * This class has the concept of a "table path". This is just a list of tables, starting
7
+ * with some "root" table and ending with the table we're currently joining to. So
8
+ * for example if we started with a "product" table and joined from there to "order_item"
9
+ * and then to "order" and then to "customer" the table path would be:
10
+ * ["product", "order_item", "order", "customer"]
11
+ * At this point the "current" table is "customer" and it's alias would be:
12
+ * "product$order_item$order$customer"
13
+ */
14
+ class QueryContext {
15
+ /**
16
+ * @param {string[]} tablePath This is the path from the "root" table to the "current table".
17
+ * @param {import("./ModelAPI").TableConfigMap} tableConfigMap
18
+ * @param {string[]} joins
19
+ */
20
+ constructor(tablePath, tableConfigMap, joins = []) {
21
+ this._tablePath = tablePath;
22
+ this._tableConfigMap = tableConfigMap;
23
+ this._joins = joins;
24
+ }
25
+
26
+ clone() {
27
+ return new QueryContext([...this._tablePath], this._tableConfigMap, [
28
+ ...this._joins,
29
+ ]);
30
+ }
31
+
32
+ /**
33
+ * Returns true if, given the current table path, a join to the given
34
+ * table has already been added.
35
+ * @param {string} table
36
+ * @returns {boolean}
37
+ */
38
+ hasJoin(table) {
39
+ const alias = joinAlias([...this._tablePath, table]);
40
+ return this._joins.includes(alias);
41
+ }
42
+
43
+ /**
44
+ * Adds table to the QueryContext's path and registers the join,
45
+ * calls fn, then pops the table off the path.
46
+ * @param {string} table
47
+ * @param {Function} fn
48
+ */
49
+ withJoin(table, fn) {
50
+ this._tablePath.push(table);
51
+ this._joins.push(this.tableAlias());
52
+
53
+ fn();
54
+
55
+ // Don't change the _joins list, we want to remember those
56
+ this._tablePath.pop();
57
+ }
58
+
59
+ /**
60
+ * Returns the alias that will be used for the current table
61
+ * @returns {string}
62
+ */
63
+ tableAlias() {
64
+ return joinAlias(this._tablePath);
65
+ }
66
+
67
+ /**
68
+ * Returns the current table name
69
+ * @returns {string}
70
+ */
71
+ tableName() {
72
+ return this._tablePath[this._tablePath.length - 1];
73
+ }
74
+
75
+ /**
76
+ * Return the TableConfig for the current table
77
+ * @returns {import("./ModelAPI").TableConfig | undefined}
78
+ */
79
+ tableConfig() {
80
+ return this._tableConfigMap[this.tableName()];
81
+ }
82
+ }
83
+
84
+ function joinAlias(tablePath) {
85
+ return tablePath.join("$");
86
+ }
87
+
88
+ module.exports = {
89
+ QueryContext,
90
+ };
@@ -0,0 +1,21 @@
1
+ class RequestHeaders {
2
+ /**
3
+ * @param {{Object.<string, string>}} requestHeaders Map of request headers submitted from the client
4
+ */
5
+
6
+ constructor(requestHeaders) {
7
+ this._headers = new Headers(requestHeaders);
8
+ }
9
+
10
+ get(key) {
11
+ return this._headers.get(key);
12
+ }
13
+
14
+ has(key) {
15
+ return this._headers.has(key);
16
+ }
17
+ }
18
+
19
+ module.exports = {
20
+ RequestHeaders,
21
+ };
@@ -0,0 +1,22 @@
1
+ const { snakeCase } = require("change-case");
2
+
3
+ function applyLimit(context, qb, limit) {
4
+ return qb.limit(limit);
5
+ }
6
+
7
+ function applyOffset(context, qb, offset) {
8
+ return qb.offset(offset);
9
+ }
10
+
11
+ function applyOrderBy(context, qb, tableName, orderBy = {}) {
12
+ Object.entries(orderBy).forEach(([key, sortOrder]) => {
13
+ qb = qb.orderBy(`${tableName}.${snakeCase(key)}`, sortOrder);
14
+ });
15
+ return qb;
16
+ }
17
+
18
+ module.exports = {
19
+ applyLimit,
20
+ applyOffset,
21
+ applyOrderBy,
22
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Adds the joins required by the where conditions to the given
3
+ * Kysely instance and returns the resulting new Kysely instance.
4
+ * @param {import("./QueryContext").QueryContext} context
5
+ * @param {import("kysely").Kysely} qb
6
+ * @param {Object} where
7
+ * @returns {import("kysely").Kysely}
8
+ */
9
+ function applyJoins(context, qb, where) {
10
+ const conf = context.tableConfig();
11
+ if (!conf) {
12
+ return qb;
13
+ }
14
+
15
+ const srcTable = context.tableAlias();
16
+
17
+ for (const key of Object.keys(where)) {
18
+ const rel = conf[key];
19
+ if (!rel) {
20
+ continue;
21
+ }
22
+
23
+ const targetTable = rel.referencesTable;
24
+
25
+ if (context.hasJoin(targetTable)) {
26
+ continue;
27
+ }
28
+
29
+ context.withJoin(targetTable, () => {
30
+ switch (rel.relationshipType) {
31
+ case "hasMany":
32
+ // For hasMany the primary key is on the source table
33
+ // and the foreign key is on the target table
34
+ qb = qb.innerJoin(
35
+ `${targetTable} as ${context.tableAlias()}`,
36
+ `${srcTable}.id`,
37
+ `${context.tableAlias()}.${rel.foreignKey}`
38
+ );
39
+ break;
40
+
41
+ case "belongsTo":
42
+ // For belongsTo the primary key is on the target table
43
+ // and the foreign key is on the source table
44
+ qb = qb.innerJoin(
45
+ `${targetTable} as ${context.tableAlias()}`,
46
+ `${srcTable}.${rel.foreignKey}`,
47
+ `${context.tableAlias()}.id`
48
+ );
49
+ break;
50
+ default:
51
+ throw new Error(`unknown relationshipType: ${rel.relationshipType}`);
52
+ }
53
+
54
+ // Keep traversing through the where conditions to see if
55
+ // more joins need to be applied
56
+ qb = applyJoins(context, qb, where[key]);
57
+ });
58
+ }
59
+
60
+ return qb;
61
+ }
62
+
63
+ module.exports = {
64
+ applyJoins,
65
+ };
@@ -0,0 +1,70 @@
1
+ const { sql } = require("kysely");
2
+ const { snakeCase } = require("./casing");
3
+
4
+ const opMapping = {
5
+ startsWith: { op: "like", value: (v) => `${v}%` },
6
+ endsWith: { op: "like", value: (v) => `%${v}` },
7
+ contains: { op: "like", value: (v) => `%${v}%` },
8
+ oneOf: { op: "in" },
9
+ greaterThan: { op: ">" },
10
+ greaterThanOrEquals: { op: ">=" },
11
+ lessThan: { op: "<" },
12
+ lessThanOrEquals: { op: "<=" },
13
+ before: { op: "<" },
14
+ onOrBefore: { op: "<=" },
15
+ after: { op: ">" },
16
+ onOrAfter: { op: ">=" },
17
+ equals: { op: sql`is not distinct from` },
18
+ notEquals: { op: sql`is distinct from` },
19
+ };
20
+
21
+ /**
22
+ * Applies the given where conditions to the provided Kysely
23
+ * instance and returns the resulting new Kysely instance.
24
+ * @param {import("./QueryContext").QueryContext} context
25
+ * @param {import("kysely").Kysely} qb
26
+ * @param {Object} where
27
+ * @returns {import("kysely").Kysely}
28
+ */
29
+ function applyWhereConditions(context, qb, where = {}) {
30
+ const conf = context.tableConfig();
31
+
32
+ for (const key of Object.keys(where)) {
33
+ const v = where[key];
34
+
35
+ // Handle nested where conditions e.g. using a join table
36
+ if (conf && conf[key]) {
37
+ const rel = conf[key];
38
+ context.withJoin(rel.referencesTable, () => {
39
+ qb = applyWhereConditions(context, qb, v);
40
+ });
41
+ continue;
42
+ }
43
+
44
+ const fieldName = `${context.tableAlias()}.${snakeCase(key)}`;
45
+
46
+ if (Object.prototype.toString.call(v) !== "[object Object]") {
47
+ qb = qb.where(fieldName, "=", v);
48
+ continue;
49
+ }
50
+
51
+ for (const op of Object.keys(v)) {
52
+ const mapping = opMapping[op];
53
+ if (!mapping) {
54
+ throw new Error(`invalid where condition: ${op}`);
55
+ }
56
+
57
+ qb = qb.where(
58
+ fieldName,
59
+ mapping.op,
60
+ mapping.value ? mapping.value(v[op]) : v[op]
61
+ );
62
+ }
63
+ }
64
+
65
+ return qb;
66
+ }
67
+
68
+ module.exports = {
69
+ applyWhereConditions,
70
+ };
package/src/casing.js ADDED
@@ -0,0 +1,30 @@
1
+ const { snakeCase, camelCase } = require("change-case");
2
+
3
+ function camelCaseObject(obj = {}) {
4
+ const r = {};
5
+ for (const key of Object.keys(obj)) {
6
+ r[camelCase(key)] = obj[key];
7
+ }
8
+ return r;
9
+ }
10
+
11
+ function snakeCaseObject(obj) {
12
+ const r = {};
13
+ for (const key of Object.keys(obj)) {
14
+ r[snakeCase(key)] = obj[key];
15
+ }
16
+ return r;
17
+ }
18
+
19
+ function upperCamelCase(s) {
20
+ s = camelCase(s);
21
+ return s[0].toUpperCase() + s.substring(1);
22
+ }
23
+
24
+ module.exports = {
25
+ camelCaseObject,
26
+ snakeCaseObject,
27
+ snakeCase,
28
+ camelCase,
29
+ upperCamelCase,
30
+ };
@@ -0,0 +1,25 @@
1
+ import { test, expect } from "vitest";
2
+ import { camelCaseObject } from "./casing";
3
+
4
+ const cases = {
5
+ FROM_SNAKE: {
6
+ input: {
7
+ id: "123",
8
+ slack_id: "xxx_2929",
9
+ api_key: "1234",
10
+ },
11
+ expected: {
12
+ id: "123",
13
+ slackId: "xxx_2929",
14
+ apiKey: "1234",
15
+ },
16
+ },
17
+ };
18
+
19
+ Object.entries(cases).map(([key, { input, expected }]) => {
20
+ test(key, () => {
21
+ const result = camelCaseObject(input);
22
+
23
+ expect(result).toEqual(expected);
24
+ });
25
+ });
package/src/consts.js ADDED
@@ -0,0 +1,13 @@
1
+ const PROTO_ACTION_TYPES = {
2
+ UNKNOWN: "OPERATION_TYPE_UNKNOWN",
3
+ CREATE: "OPERATION_TYPE_CREATE",
4
+ GET: "OPERATION_TYPE_GET",
5
+ LIST: "OPERATION_TYPE_LIST",
6
+ UPDATE: "OPERATION_TYPE_UPDATE",
7
+ DELETE: "OPERATION_TYPE_DELETE",
8
+ READ: "OPERATION_TYPE_READ",
9
+ WRITE: "OPERATION_TYPE_WRITE",
10
+ JOB: "JOB_TYPE",
11
+ };
12
+
13
+ module.exports.PROTO_ACTION_TYPES = PROTO_ACTION_TYPES;
@@ -0,0 +1,163 @@
1
+ const { Kysely, PostgresDialect, CamelCasePlugin } = require("kysely");
2
+ const { AsyncLocalStorage } = require("async_hooks");
3
+ const pg = require("pg");
4
+ const { PROTO_ACTION_TYPES } = require("./consts");
5
+ const { withSpan } = require("./tracing");
6
+
7
+ // withDatabase is responsible for setting the correct database client in our AsyncLocalStorage
8
+ // so that the the code in a custom function uses the correct client.
9
+ // For GET and LIST action types, no transaction is used, but for
10
+ // actions that mutate data such as CREATE, DELETE & UPDATE, all of the code inside
11
+ // the user's custom function is wrapped in a transaction so we can rollback
12
+ // the transaction if something goes wrong.
13
+ // withDatabase shouldn't be exposed in the public api of the sdk
14
+ async function withDatabase(db, actionType, cb) {
15
+ let requiresTransaction = true;
16
+
17
+ switch (actionType) {
18
+ case PROTO_ACTION_TYPES.JOB:
19
+ case PROTO_ACTION_TYPES.GET:
20
+ case PROTO_ACTION_TYPES.LIST:
21
+ requiresTransaction = false;
22
+ break;
23
+ }
24
+
25
+ if (requiresTransaction) {
26
+ return db.transaction().execute(async (transaction) => {
27
+ return dbInstance.run(transaction, async () => {
28
+ return cb({ transaction });
29
+ });
30
+ });
31
+ }
32
+
33
+ return dbInstance.run(db, async () => {
34
+ return cb({ transaction: db });
35
+ });
36
+ }
37
+
38
+ let db = null;
39
+
40
+ const dbInstance = new AsyncLocalStorage();
41
+
42
+ // useDatabase will retrieve the database client set by withDatabase from the local storage
43
+ function useDatabase() {
44
+ // retrieve the instance of the database client from the store which is aware of
45
+ // which context the current connection to the db is running in - e.g does the context
46
+ // require a transaction or not?
47
+ let fromStore = dbInstance.getStore();
48
+ if (fromStore) {
49
+ return fromStore;
50
+ }
51
+
52
+ // if the NODE_ENV is 'test' then we know we are inside of the vitest environment
53
+ // which covers any test files ending in *.test.ts. Custom function code runs in a different node process which will not have this environment variable. Tests written using our testing
54
+ // framework call actions (and in turn custom function code) over http using the ActionExecutor class
55
+ if ("NODE_ENV" in process.env && process.env.NODE_ENV == "test") {
56
+ return getDatabaseClient();
57
+ }
58
+
59
+ // If we've gotten to this point, then we know that we are in a custom function runtime server
60
+ // context and we haven't been able to retrieve the in-context instance of Kysely, which means we should throw an error.
61
+ throw new Error("useDatabase must be called within a function");
62
+ }
63
+
64
+ // getDatabaseClient will return a brand new instance of Kysely. Every instance of Kysely
65
+ // represents an individual connection to the database.
66
+ // not to be exported externally from our sdk - consumers should use useDatabase
67
+ function getDatabaseClient() {
68
+ // 'db' represents the singleton connection to the database which is stored
69
+ // as a module scope variable.
70
+ if (db) {
71
+ return db;
72
+ }
73
+
74
+ db = new Kysely({
75
+ dialect: getDialect(),
76
+ plugins: [
77
+ // allows users to query using camelCased versions of the database column names, which
78
+ // should match the names we use in our schema.
79
+ // https://kysely-org.github.io/kysely/classes/CamelCasePlugin.html
80
+ // If they don't, then we can create a custom implementation of the plugin where we control
81
+ // the casing behaviour (see url above for example)
82
+ new CamelCasePlugin(),
83
+ ],
84
+ log(event) {
85
+ if ("DEBUG" in process.env) {
86
+ if (event.level === "query") {
87
+ console.log(event.query.sql);
88
+ console.log(event.query.parameters);
89
+ }
90
+ }
91
+ },
92
+ });
93
+
94
+ return db;
95
+ }
96
+
97
+ class InstrumentedPool extends pg.Pool {
98
+ async connect(...args) {
99
+ const _super = super.connect.bind(this);
100
+ return withSpan("Database Connect", function () {
101
+ return _super(...args);
102
+ });
103
+ }
104
+ }
105
+
106
+ const txStatements = {
107
+ begin: "Transaction Begin",
108
+ commit: "Transaction Commit",
109
+ rollback: "Transaction Rollback",
110
+ };
111
+
112
+ class InstrumentedClient extends pg.Client {
113
+ async query(...args) {
114
+ const _super = super.query.bind(this);
115
+ const sql = args[0];
116
+
117
+ let sqlAttribute = false;
118
+
119
+ let spanName = txStatements[sql.toLowerCase()];
120
+ if (!spanName) {
121
+ spanName = "Database Query";
122
+ sqlAttribute = true;
123
+ }
124
+
125
+ return withSpan(spanName, function (span) {
126
+ if (sqlAttribute) {
127
+ span.setAttribute("sql", args[0]);
128
+ }
129
+ return _super(...args);
130
+ });
131
+ }
132
+ }
133
+
134
+ function getDialect() {
135
+ const dbConnType = process.env["KEEL_DB_CONN_TYPE"];
136
+ switch (dbConnType) {
137
+ case "pg":
138
+ return new PostgresDialect({
139
+ pool: new InstrumentedPool({
140
+ Client: InstrumentedClient,
141
+ connectionString: mustEnv("KEEL_DB_CONN"),
142
+ }),
143
+ });
144
+
145
+ default:
146
+ throw Error("unexpected KEEL_DB_CONN_TYPE: " + dbConnType);
147
+ }
148
+ }
149
+
150
+ function mustEnv(key) {
151
+ const v = process.env[key];
152
+ if (!v) {
153
+ throw new Error(`expected environment variable ${key} to be set`);
154
+ }
155
+ return v;
156
+ }
157
+
158
+ // initialise the database client at module scope level so the db variable is set
159
+ getDatabaseClient();
160
+
161
+ module.exports.getDatabaseClient = getDatabaseClient;
162
+ module.exports.useDatabase = useDatabase;
163
+ module.exports.withDatabase = withDatabase;