node-cosmos
Version:
A light weight azure cosmosdb client aiming at ease of use for creating REST API. Supports json filter, sort and offset/limit
238 lines • 10.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.PostgresDatabaseImpl = exports.ensureIdentifier = exports.addExpireAt = void 0;
const CosmosDatabase_1 = require("../../CosmosDatabase");
const CosmosContainer_1 = require("../../CosmosContainer");
const PostgresConditionBuilder_1 = require("./PostgresConditionBuilder");
const assert_1 = require("../../../util/assert");
const uuid_1 = require("uuid");
/** Characters disallowed in schema/table names based on PostgreSQL identifier docs. */
const invalidIdentifierChars = [";", ",", "&", "'", "\\", "(", ")", "\t", "\n", "\r"];
/** Validates document ids for basic safety. */
const checkValidId = (id) => {
if (!id) {
throw new Error("id cannot be empty");
}
if (id.includes("\t") || id.includes("\n") || id.includes("\r")) {
throw new Error("id cannot contain \t or \n or \r");
}
};
/** Adds a Cosmos-like timestamp stamp in seconds. */
const addTimestamp = (data) => {
const epochMillis = Date.now();
data["_ts"] = epochMillis / 1000;
};
/** Derives an `_expireAt` epoch second value when ttl is a valid number. */
const addExpireAt = (data) => {
const ttlValue = data["ttl"];
if (typeof ttlValue !== "number" || !Number.isFinite(ttlValue)) {
delete data["_expireAt"];
return undefined;
}
const ttlSeconds = Math.trunc(ttlValue);
const epochSeconds = Math.floor(Date.now() / 1000) + ttlSeconds;
data["_expireAt"] = epochSeconds;
return epochSeconds;
};
exports.addExpireAt = addExpireAt;
/** Throws when the provided identifier is empty or invalid. */
const ensureIdentifier = (value, label) => {
(0, assert_1.assertNotEmpty)(value, label);
if (invalidIdentifierChars.some((ch) => value.includes(ch))) {
throw new Error(`${label} should not contain invalid characters: ${value}`);
}
if (value.includes("--")) {
throw new Error(`${label} should not contain '--': ${value}`);
}
};
exports.ensureIdentifier = ensureIdentifier;
/** Wraps schema/table names with quotes and dot. */
const qualify = (schema, table) => {
return `"${schema}"."${table}"`;
};
/**
* CosmosDatabase implementation that stores JSON documents inside
* PostgreSQL tables and partitions them by schema/table mapping.
*/
class PostgresDatabaseImpl {
/**
* @param pool pg Pool used for queries.
* @param dbName Logical database name, used to scope containers.
*/
constructor(pool, dbName) {
/** Cached CosmosContainers keyed by logical collection name. */
this.collectionMap = new Map();
this.pool = pool;
this.dbName = dbName;
}
/** Lazily creates a CosmosContainer for a collection. */
async createCollection(coll) {
(0, exports.ensureIdentifier)(coll, "coll");
const container = new CosmosContainer_1.CosmosContainer(coll, { schema: coll });
this.collectionMap.set(coll, container);
return container;
}
/** Removes cached metadata for a collection. */
async deleteCollection(coll) {
this.collectionMap.delete(coll);
}
/** Gets or creates the CosmosContainer for the provided collection. */
async getCollection(coll) {
let collection = this.collectionMap.get(coll);
if (!collection) {
collection = await this.createCollection(coll);
}
return collection;
}
/** Inserts a new document into `<schema>.<partition>` jsonb table. */
async create(coll, data, partition = coll) {
(0, assert_1.assertNotEmpty)(coll, "coll");
(0, assert_1.assertNotEmpty)(partition, "partition");
(0, assert_1.assertIsDefined)(data, "data");
(0, exports.ensureIdentifier)(coll, "coll");
(0, exports.ensureIdentifier)(partition, "partition");
const schema = coll;
const table = partition;
const fqtn = qualify(schema, table);
const id = data.id || (0, uuid_1.v4)().toString();
checkValidId(id);
const payload = { ...data, id };
payload[CosmosDatabase_1._partition] = partition;
addTimestamp(payload);
(0, exports.addExpireAt)(payload);
const text = `INSERT INTO ${fqtn} (id, data) VALUES ($1, $2::jsonb) RETURNING data`;
const values = [id, JSON.stringify(payload)];
const result = await this.pool.query(text, values);
return this.extractResource(result);
}
/** Reads a document and throws 404 when not found. */
async read(coll, id, partition = coll) {
const resource = await this.readOrDefault(coll, id, partition, null);
if (!resource) {
throw new CosmosDatabase_1.CosmosError(undefined, 404, `item not found. id:${id}`);
}
return resource;
}
/** Reads a document and falls back to default when missing. */
async readOrDefault(coll, id, partition, defaultValue) {
(0, assert_1.assertNotEmpty)(coll, "coll");
(0, assert_1.assertNotEmpty)(partition, "partition");
(0, assert_1.assertNotEmpty)(id, "id");
(0, exports.ensureIdentifier)(coll, "coll");
(0, exports.ensureIdentifier)(partition, "partition");
const fqtn = qualify(coll, partition);
const text = `SELECT data FROM ${fqtn} WHERE id = $1`;
const values = [id];
const result = await this.pool.query(text, values);
if (!result.rowCount) {
return defaultValue;
}
return result.rows[0].data;
}
/** Inserts or replaces a document by id. */
async upsert(coll, data, partition = coll) {
(0, assert_1.assertNotEmpty)(coll, "coll");
(0, assert_1.assertNotEmpty)(partition, "partition");
(0, assert_1.assertIsDefined)(data, "data");
(0, exports.ensureIdentifier)(coll, "coll");
(0, exports.ensureIdentifier)(partition, "partition");
const schema = coll;
const table = partition;
const fqtn = qualify(schema, table);
const id = data.id || (0, uuid_1.v4)().toString();
checkValidId(id);
const payload = { ...data, id };
payload[CosmosDatabase_1._partition] = partition;
addTimestamp(payload);
(0, exports.addExpireAt)(payload);
const text = `INSERT INTO ${fqtn} (id, data)
VALUES ($1, $2::jsonb)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data
RETURNING data`;
const values = [id, JSON.stringify(payload)];
const result = await this.pool.query(text, values);
return this.extractResource(result);
}
/** Updates an existing document and merges payload with stored value. */
async update(coll, data, partition = coll) {
(0, assert_1.assertNotEmpty)(coll, "coll");
(0, assert_1.assertNotEmpty)(partition, "partition");
(0, assert_1.assertIsDefined)(data, "data");
(0, assert_1.assertIsDefined)(data.id, "data.id");
(0, exports.ensureIdentifier)(coll, "coll");
(0, exports.ensureIdentifier)(partition, "partition");
const existing = await this.read(coll, data.id, partition);
const payload = { ...existing, ...data };
payload[CosmosDatabase_1._partition] = partition;
addTimestamp(payload);
(0, exports.addExpireAt)(payload);
const fqtn = qualify(coll, partition);
const text = `UPDATE ${fqtn} SET data = $1::jsonb WHERE id = $2 RETURNING data`;
const targetId = payload.id;
const values = [JSON.stringify(payload), targetId];
const result = await this.pool.query(text, values);
if (!result.rowCount) {
throw new CosmosDatabase_1.CosmosError(undefined, 404, `item not found. id:${data.id}`);
}
return this.extractResource(result);
}
/** Deletes a document by id and returns the CosmosId when successful. */
async delete(coll, id, partition = coll) {
(0, assert_1.assertNotEmpty)(coll, "coll");
(0, assert_1.assertNotEmpty)(partition, "partition");
(0, assert_1.assertNotEmpty)(id, "id");
(0, exports.ensureIdentifier)(coll, "coll");
(0, exports.ensureIdentifier)(partition, "partition");
const fqtn = qualify(coll, partition);
const text = `DELETE FROM ${fqtn} WHERE id = $1 RETURNING id`;
const values = [id];
const result = await this.pool.query(text, values);
if (!result.rowCount) {
return undefined;
}
return { id };
}
/** Finds documents by translating a Condition into SQL. */
async find(coll, condition, partition = coll) {
(0, assert_1.assertNotEmpty)(coll, "coll");
(0, assert_1.assertNotEmpty)(partition, "partition");
(0, exports.ensureIdentifier)(coll, "coll");
(0, exports.ensureIdentifier)(partition, "partition");
const fqtn = qualify(coll, partition);
const builder = new PostgresConditionBuilder_1.PostgresConditionBuilder();
const { whereClause, orderClause, limitClause, params } = builder.build(condition);
const clauses = [whereClause, orderClause, limitClause].filter((clause) => clause);
const text = [`SELECT data FROM ${fqtn} AS t`, ...clauses].join(" ");
const result = await this.pool.query(text, params);
return result.rows.map((row) => row.data);
}
/** Not implemented helper, kept for Cosmos interface compatibility. */
async findBySQL(coll, query, partition) {
throw new Error("findBySQL is not supported for postgresql");
}
/** Counts documents matching the provided Condition. */
async count(coll, condition, partition = coll) {
var _a;
(0, assert_1.assertNotEmpty)(coll, "coll");
(0, assert_1.assertNotEmpty)(partition, "partition");
(0, exports.ensureIdentifier)(coll, "coll");
(0, exports.ensureIdentifier)(partition, "partition");
const fqtn = qualify(coll, partition);
const builder = new PostgresConditionBuilder_1.PostgresConditionBuilder();
const { whereClause, params } = builder.build(condition, true);
const clauses = [whereClause].filter((clause) => clause);
const text = [`SELECT COUNT(*)::int AS count FROM ${fqtn} AS t`, ...clauses].join(" ");
const result = await this.pool.query(text, params);
return Number(((_a = result.rows[0]) === null || _a === void 0 ? void 0 : _a.count) || 0);
}
/** Extracts the first row data or throws CosmosError when empty. */
extractResource(result) {
(0, assert_1.assertIsDefined)(result.rowCount);
if (!result.rowCount) {
throw new CosmosDatabase_1.CosmosError(undefined, 404, "item not found");
}
return result.rows[0].data;
}
}
exports.PostgresDatabaseImpl = PostgresDatabaseImpl;
//# sourceMappingURL=PostgresDatabaseImpl.js.map