imago-azure-storage
Version:
An opinionated async wrapper for azure-storage for working with Azure Storage such as tables and queues.
296 lines (263 loc) • 9.84 kB
JavaScript
/* eslint no-param-reassign:0 */
const Azure = require('azure-storage');
const Generator = Azure.TableUtilities.entityGenerator;
const {
azureTablesArrayToObject,
azureTablesRowToObject,
} = require('./common');
/**
* Handles CRUD operations for Azure Tables, a cheaper and faster alternative
* to SQL databases when relational databases are not needed.
*/
class AzureTableStorage {
constructor(storageAccount, storageAccessKey) {
try {
this.service = Azure.createTableService(storageAccount, storageAccessKey);
this.storageAccount = storageAccount;
} catch (error) {
console.log('Error initializing Azure Table Storage: ', error);
throw error;
}
}
/**
* Creates tables if they don't exist yet.
* @param {string[]} tables - Table names.
*/
async initializeTables(tables) {
for (const table of tables) {
/* eslint-disable-next-line no-await-in-loop */
await new Promise((resolve, reject) => {
console.log(`Initializing table ${table}...`);
this.service.createTableIfNotExists(table, (error) => {
if (error) {
reject(error);
} else {
resolve(error);
}
});
});
}
}
/**
* Reads a specified row from the table.
* @param {any} table
* @param {any} partition
* @param {any} key
*/
async retrieveEntity(table, partition, key) {
if (table === null || table === undefined) {
throw new Error('Empty table name when trying to retrieve an entity.');
}
if (partition === null || partition === undefined) {
throw new Error('Empty partition key when trying to retrieve a Table entity.');
}
if (key === null || key === undefined) {
throw new Error('Empty row key when trying to retrieve a Table entity.');
}
return new Promise((resolve, reject) => {
// Numbers are not allowed:
partition = partition.toString();
key = key.toString();
this.service.retrieveEntity(table, partition, key, (error, result) => {
if (error) {
reject(error);
} else {
// Convert the response format where values are stored in the _
// key into a normal object format:
resolve(azureTablesRowToObject(result));
}
});
});
}
/**
* Retrieves all entities from a partition.
* @param {string} table - The name of the Azure Table.
* @param {string} partition - The PartitionKey.
* @param {number} maxPages - Max number of pages to fetch. However, some pages
* could be empty due to being on a different database server.
*/
async retrieveAllEntitiesByPartitionKey(table, partition, maxPages = 0) {
return new Promise((resolve, reject) => {
const query = new Azure.TableQuery().where('PartitionKey eq ?', partition);
this.service.queryEntities(table, query, null, async (error, result) => {
if (error) {
reject(error);
} else {
const processedData = await azureTablesArrayToObject(result.entries);
let { continuationToken } = result;
let pagesFetched = 0;
while (continuationToken && (maxPages ? pagesFetched < maxPages : true)) {
/* eslint-disable no-await-in-loop */
const data = await this.repeatWithContinuationToken(table, query, continuationToken);
({ continuationToken } = data);
processedData.push(...await azureTablesArrayToObject(data.entries));
pagesFetched += 1;
}
resolve(processedData);
}
});
});
}
/**
* Retrieves all records from the table (currently up to 1000, TODO FIXME).
* @param {string} table - The name of the table.
* @param {number} maxPages - Max number of pages to fetch. However, some pages
* could be empty due to being on a different database server.
*/
async retrieveEntireTable(table, maxPages = 0) {
return new Promise((resolve, reject) => {
const query = new Azure.TableQuery();
this.service.queryEntities(table, query, null, async (error, result) => {
if (error) {
reject(error);
} else {
const processedData = await azureTablesArrayToObject(result.entries);
let { continuationToken } = result;
let pagesFetched = 0;
while (continuationToken && (maxPages ? pagesFetched < maxPages : true)) {
/* eslint-disable no-await-in-loop */
const data = await this.repeatWithContinuationToken(table, query, continuationToken);
({ continuationToken } = data);
processedData.push(...await azureTablesArrayToObject(data.entries));
pagesFetched += 1;
}
resolve(processedData);
}
});
});
}
/**
* Adds or replaces an entity where all values are strings.
* @param {string} table - Table name.
* @param {string|object} partition - Partition key, or data if initializing
* with two parameters.
* @param {string} key - Row key (row ID).
* @param {object} data - The data object to be saved, where object keys are
* column names.
*/
async saveStringOnlyEntity(table, partition, key, data) {
// Allow calling with just two params, when the two keys are inside
// the data:
if (typeof partition === 'object') {
data = partition;
partition = data.PartitionKey;
key = data.RowKey;
if (!partition || !key) {
throw new Error('Empty partition key or row key when trying to save.');
}
}
const entity = {
PartitionKey: Generator.String(partition.toString()),
RowKey: Generator.String(key.toString()),
};
for (const dataKey of Object.keys(data)) {
entity[dataKey] = Generator.String(data[dataKey] && data[dataKey].toString
? data[dataKey].toString()
: '');
}
return new Promise((resolve, reject) => {
this.service.insertOrReplaceEntity(table, entity, (error) => {
if (error) {
reject(error);
} else {
resolve(error);
}
});
});
}
/**
* Deletes the row with the specified key.
* @param {string} table
* @param {string} partition
* @param {string} key - The row key of the entity to be deleted.
*/
async deleteEntity(table, partition, key) {
return new Promise((resolve, reject) => {
this.service.deleteEntity(
table,
{
PartitionKey: partition,
RowKey: key,
},
(error, success, response) => {
if (!error && success) {
resolve(success);
} else {
reject(response);
}
},
);
});
}
/**
* Queries a table based on the column values.
* @param {string} table
* @param {string} partition - The PartitionKey. If null or empty, all
* partition keys will be searched (very slow and expensive);
* @param {string[][]} conditions - An array of arrays, where each condition
* is an array containing two or more elements, for example ['userName eq ?', 'Anton']
* or ['hitCount >= ? and hitCount <= ?', '2', '100'].
* @param {number} maxPages - Max number of pages to fetch. However, some pages
* could be empty due to being on a different database server.
*/
async query(table, partition, conditions, maxPages = 0) {
conditions = conditions || [];
return new Promise((resolve, reject) => {
let query;
if (partition) {
query = new Azure.TableQuery()
.where('PartitionKey eq ?', partition);
for (const condition of conditions) {
query = query.and(...condition);
}
} else {
// Empty partition key:
query = new Azure.TableQuery();
let firstIteration = true;
for (const condition of conditions) {
if (firstIteration) {
query = query.where(...condition);
firstIteration = false;
} else {
query = query.and(...condition);
}
}
}
this.service.queryEntities(table, query, null, async (error, result) => {
if (error) {
reject(error);
} else {
const processedData = await azureTablesArrayToObject(result.entries);
// If a continuation token is returned, fetch the remaining items.
// Keep querying until the continuation token is null:
let { continuationToken } = result;
let pagesFetched = 0;
while (continuationToken && (maxPages ? pagesFetched < maxPages : true)) {
/* eslint-disable no-await-in-loop */
const data = await this.repeatWithContinuationToken(table, query, continuationToken);
({ continuationToken } = data);
processedData.push(...await azureTablesArrayToObject(data.entries));
pagesFetched += 1;
}
resolve(processedData);
}
});
});
}
/**
* Repeats a table query with a continuation token which is returned when
* there are more than 1000 results or the query spans multiple servers.
*/
async repeatWithContinuationToken(table, query, continuationToken) {
return new Promise((resolve, reject) => {
this.service.queryEntities(table, query, continuationToken, async (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
}
}
module.exports = AzureTableStorage;