"use strict";
const _ = require("lodash");
const AsyncApi = require("./AsyncApi");
const SyncApi = require("./SyncApi");
function argumentsToMultiLanguageObj(argsObj) {
const args = _.toArray(argsObj);
const langtagRegex = /[a-z]{2,3}[-_][A-Z]{2,3}|[a-z]{2,3}/;
let obj = {};
if (args.length === 1 && _.isPlainObject(args[0])) {
let valid = _.every(_.keys(args[0]), function (key) {
return langtagRegex.test(key);
});
if (valid) {
obj = args[0];
} else {
throw new Error("arguments must be either key/value list (e.g. de-DE, Tabelle, en-GB, table) or a plain object");
}
} else if (_.isArray(args) && args.length % 2 === 0) {
let object = {};
for (let i = 0; i < args.length; i += 2) {
const langtag = args[i];
const value = args[i + 1];
if (langtag !== undefined && langtagRegex.test(langtag) && value !== undefined) {
object[langtag] = value;
} else {
throw new Error("Arguments are wrong. undefined or wrong langtag. (" + JSON.stringify(args) + ")");
}
}
obj = object;
} else {
console.log("invalid args", args, _.isArray(args), _.isPlainObject(args), _.toArray(args));
throw new Error("arguments must be either key/value list (e.g. de-DE, Tabelle, en-GB, table) or a plain object");
}
return obj;
}
/**
* @typedef {object} GRUDStructorizer
* @property api {SyncApi}
* @property asyncApi {AsyncApi}
* @property Table {Table}
* @property Tables {Tables}
* @property TableBuilder {TableBuilder}
* @property ColumnBuilder {ColumnBuilder}
* @property ConstraintBuilder {ConstraintBuilder}
*/
/**
*
* @param baseUrl {string}
* @param options {object}
* @returns {GRUDStructorizer}
*/
function grudStructorizer(baseUrl, options) {
const syncApi = new SyncApi(baseUrl, options);
const asyncApi = new AsyncApi(baseUrl, options);
const StaticHelpers = {
getLanguages: () => {
return syncApi.doCall("GET", "/system/settings/langtags").value;
},
checkKindForLanguageConversion: (kind) => {
const ALLOWED_TYPES = ["shorttext", "text"];
if (!_.includes(ALLOWED_TYPES, kind)) {
throw new Error(`Column must be of kind '${_.join(ALLOWED_TYPES, "' or '")}'`);
}
},
checkLanguageForLanguageConversion: (languages, targetLanguage) => {
if (!_.includes(languages, targetLanguage)) {
throw new Error(`Language '${targetLanguage}' not in '/system/settings/langtags'`);
}
}
};
/**
*
*/
class Tables {
/**
*
*/
constructor() {
this.tables = [];
}
/**
* Fetches all tables
*
* @returns {Tables}
*/
fetch() {
Object.assign(this, syncApi.doCall("GET", "/tables"));
return this;
}
/**
* Searches for a specific table. Fetch tables first
*
* @param tableName {string}
* @returns {Table}
*/
find(tableName) {
const table = _.find(this.tables, { name: tableName });
if (table) {
return new Table(table.id, table.name);
}
}
}
/**
* @typedef {object} Column
* @property id {number}
* @property name {string}
* @property kind {string}
*/
/**
*
*/
class Table {
/**
*
* @param tableId {number}
* @param tableName {string}
*/
constructor(tableId, tableName) {
if (typeof tableId !== "number") {
throw new Error("parameter 'tableId' should be a number");
}
if (typeof tableName !== "string") {
throw new Error("parameter 'tableName' should be a string");
}
this.tableId = tableId;
this.name = tableName;
this.columns = [];
}
/**
* Fetches meta and columns for this Table object.
*
* @param includeRows retrieves rows (default: false) {boolean}
* @returns {Table}
*/
fetch(includeRows = false) {
Object.assign(this, syncApi.fetchTable(this.tableId, includeRows));
return this;
}
/**
* Returns an array of row objects zipped with column names for this Table.
*
* The `rowId` property represents the row ID (PK) of the database,
* so this value can be reused for updates/deletions/etc.
*
* @returns {Array.<object>} array row objects
*/
getRows() {
if (!this.columns || !this.rows) {
throw new Error("Fetch table and rows first");
}
return _.map(
this.rows,
(row) => {
const obj = _.zipObject(_.map(this.columns, "name"), row.values);
obj.rowId = row.id;
return obj;
}
);
}
/**
* Returns a single row object zipped with column names for this Table.
*
* The `rowId` property represents the row ID (PK) of the database,
* so this value can be reused for updates/deletions/etc.
*
* @param id {number}
* @returns {Object} row object
*/
getRow(id) {
if (!this.columns || !this.rows) {
throw new Error("Fetch table and rows first");
}
if (typeof id !== "number") {
throw new Error("Parameter 'id' should be a number");
}
const foundRow = _.find(this.rows, (row) => row.id === id);
if (!foundRow) {
throw new Error("No row found for id '" + id + "'");
}
const obj = _.zipObject(_.map(this.columns, "name"), foundRow.values);
obj.rowId = foundRow.id;
return obj;
}
/**
*
* @param nameOrId {string|number}
*/
findColumn(nameOrId) {
if (!this.columns) {
throw new Error("Fetch table first");
}
return _.find(this.columns, function (column) {
if (_.isInteger(nameOrId)) {
return column.id === nameOrId;
} else {
return column.name === nameOrId;
}
});
}
/**
*
* @param columnBuilderArray {Array.<ConstraintBuilder>}
* @returns {Array.<Column>}
*/
createColumns(columnBuilderArray) {
if (typeof this.tableId === "undefined") {
throw new Error("table " + this.name + " should be created first");
}
const columnObjArray = columnBuilderArray.map(function (columnBuilder) {
return columnBuilder.build();
});
const self = this;
columnObjArray.forEach(function (columnObject) {
self.columns.forEach(function (column) {
if (column.name === columnObject.name) {
throw new Error("column " + columnObject.name + " can't be created because its name " + columnObject.name + " is already used");
}
});
});
const newColumns = syncApi.createColumns(this.tableId, columnObjArray);
newColumns.forEach(function (newColumn) {
self.columns.push(newColumn);
});
return newColumns;
}
/**
*
* @param nameOrId {string|number}
*/
deleteColumn(nameOrId) {
const column = this.findColumn(nameOrId);
if (!column) {
throw new Error("No column with this name or ID found '" + nameOrId + "'");
}
const response = syncApi.doCall("DELETE", "/tables/" + this.tableId + "/columns/" + column.id);
if (response) {
_.remove(this.columns, function (c) {
return c.id === column.id;
});
}
}
/**
*
* @param columnBuilder {ColumnBuilder}
* @return {number} column id
*/
createColumn(columnBuilder) {
if (typeof this.tableId === "undefined") {
throw new Error("table " + this.name + " should be created first");
}
const columnObject = columnBuilder.build();
this.columns.forEach(function (column) {
if (column.name === columnObject.name) {
throw new Error("column " + columnObject.name + " can't be created because its name " + columnObject.name + " is already used");
}
});
const newColumn = syncApi.createColumn(this.tableId, columnBuilder.build());
this.columns.push(newColumn);
return newColumn.id;
}
getValuesFromCreateRowByObj(columnNameToValueObject) {
if (!this.columns) {
throw new Error("table needs to be fetched first, columns should be defined");
}
if (!_.isPlainObject(columnNameToValueObject)) {
throw new Error("columnNameToValueObject should be a simple plain object");
}
const columnNames = _.keys(columnNameToValueObject);
const columnIdToValueArray = columnNames.map((columnName) => {
const column = _.find(this.columns, ["name", columnName]);
if (!column) {
throw new Error("column '" + columnName + "' is not defined in table '" + this.name + "'");
}
return [column.id, columnNameToValueObject[columnName]];
});
const columnIds = _.map(columnIdToValueArray, _.first);
const values = _.map(columnIdToValueArray, _.last);
return {
columnIds,
values
};
}
/**
*
* @param columnNameToValueObject {object}
* @returns {number} row id
*/
createRowByObj(columnNameToValueObject) {
const { columnIds, values } = this.getValuesFromCreateRowByObj(columnNameToValueObject);
return this.createRows([values], columnIds)[0];
}
/**
*
* @returns {number} row id
*/
createRow() {
// convert arguments to array and
// hand it over to createRows with just one row
return this.createRows([_.toArray(arguments)])[0];
}
/**
*
* @param rows {Array.<Array.<any>>}
* @param columns {Array.<number>}
* @returns {Array.<number>} array of row ids
*/
createRows(rows, columns) {
if (typeof this.tableId === "undefined") {
throw new Error("table should be created first");
}
const firstRowValues = _.head(rows) || [];
const sameLength = _.every(rows, function (rowValues) {
return rowValues.length === firstRowValues.length;
});
if (!sameLength) {
throw new Error("all rows need to have same values length");
}
// generate IDs based on rowValues length
const columnIds = columns || _.range(1, firstRowValues.length + 1);
return syncApi.createRows(this.tableId, columnIds, rows);
}
changeColumn(columnId, changeObj) {
return syncApi.doCall("POST", "/tables/" + this.tableId + "/columns/" + columnId, changeObj);
}
getColumn(columnName) {
const column = this.findColumn(columnName);
if (!column) {
throw new Error(`Column name '${columnName}' does not exist`);
}
return column;
};
/**
* Convenient method to change a single language column to multi language
*
* @param columnName {string}
* @param pickLanguage language in which raw values should be inserted (default: "first language of
* '/system/settings/langtags'") {string}
*/
convertColumnToMultilanguage(columnName, pickLanguage) {
this.fetch();
const column = this.getColumn(columnName);
const { ordering, kind, identifier, displayName, description, multilanguage, maxLength, minLength } = _.find(this.columns, { name: columnName });
const languages = StaticHelpers.getLanguages();
const defaultLanguage = _.head(languages);
StaticHelpers.checkLanguageForLanguageConversion(languages, pickLanguage || defaultLanguage);
StaticHelpers.checkKindForLanguageConversion(kind);
if (multilanguage) {
throw new Error("Column is already multi language");
}
const columnIndex = _.findIndex(this.columns, { name: columnName });
this.changeColumn(column.id, { name: columnName + "_convert_language" });
this.fetch(true);
const newColumnId = this.createColumn(
new ColumnBuilder(columnName, kind).displayName(displayName).identifier(identifier).description(description).ordering(ordering).maxLength(maxLength).minLength(minLength).multilanguage(true),
);
_.forEach(this.rows, row => {
const { id: rowId, values } = row;
const value = values[columnIndex];
const url = "/tables/" + this.tableId + "/columns/" + newColumnId + "/rows/" + rowId;
if (!value) {
return;
}
const mapValueIntoLanguage = (value, lang) => {
return {
value: {
[lang]: value
}
};
};
const newValue = mapValueIntoLanguage(value, pickLanguage || defaultLanguage);
syncApi.doCall("PATCH", url, newValue);
syncApi.doCall("POST", `${url}/annotations`, {
langtags: languages,
type: "flag",
value: "needs_translation"
});
});
syncApi.doCall("DELETE", "/tables/" + this.tableId + "/columns/" + column.id);
};
/**
* Convenient method to change a multi language column to single language
* @param columnName {string}
* @param pickLanguage language from which values are taken as new values (default: first language of
* '/system/settings/langtags') {string}
*/
convertColumnToSinglelanguage(columnName, pickLanguage) {
this.fetch();
const column = this.getColumn(columnName);
const { ordering, kind, identifier, displayName, description, multilanguage, maxLength, minLength } = _.find(this.columns, { name: columnName });
const languages = StaticHelpers.getLanguages();
const defaultLanguage = _.head(languages);
StaticHelpers.checkLanguageForLanguageConversion(languages, pickLanguage || defaultLanguage);
StaticHelpers.checkKindForLanguageConversion(kind);
if (!multilanguage) {
throw new Error("Column is already single language");
}
const columnIndex = _.findIndex(this.columns, { name: columnName });
this.changeColumn(column.id, { name: columnName + "_convert_language" });
this.fetch(true);
const newColumnId = this.createColumn(
new ColumnBuilder(columnName, kind).displayName(displayName).identifier(identifier).description(description).ordering(ordering).maxLength(maxLength).minLength(minLength).multilanguage(false),
);
_.forEach(this.rows, row => {
const { id: rowId, values, annotations } = row;
const newValue = _.get(values[columnIndex], pickLanguage || defaultLanguage);
const url = "/tables/" + this.tableId + "/columns/" + newColumnId + "/rows/" + rowId;
if (!newValue) {
return;
}
syncApi.doCall("PATCH", url, { value: newValue });
if (_.includes(annotations, columnIndex)) {
// there schould be not more than one translation flag per cell
const langAnnotation = _.head(_.filter(annotations[columnIndex], { "value": "needs_translation" }));
if (langAnnotation) {
syncApi.doCall("DELETE", `${url}/annotations/${langAnnotation.uuid}`);
}
}
});
syncApi.doCall("DELETE", "/tables/" + this.tableId + "/columns/" + column.id);
}
}
/**
*
*/
class TableBuilder {
/**
*
* @param name {string}
* @param type {("generic"|"settings")}
*/
constructor(name, type) {
const ALLOWED_TYPES = ["generic", "settings", "taxonomy"];
this.name = name;
this._groupId = null;
if (ALLOWED_TYPES.includes(type)) {
this.type = type;
} else {
throw new Error("invalid table type");
}
}
/**
*
* @param args {Array.<object|string>} one multi-language object or langtag-value list
* @returns {TableBuilder}
*/
displayName(...args) {
this._displayName = argumentsToMultiLanguageObj(args);
return this;
}
/**
*
* @param [hidden=true] {boolean}
* @returns {TableBuilder}
*/
hidden(hidden) {
this._hidden = typeof hidden === "boolean" ? hidden : true;
return this;
}
/**
*
* @param groupId {number}
* @returns {TableBuilder}
*/
group(groupId) {
if (typeof groupId !== "number") {
throw new Error("groupId should be a int");
}
this._groupId = groupId;
return this;
}
/**
*
* @returns {Table}
*/
create() {
const tableId = syncApi.createTable(this.name, this._hidden, this._displayName, this.type, this._groupId).id;
return new Table(tableId, this.name);
}
}
/**
*
*/
class ColumnBuilder {
/**
*
* @param name {string}
* @param kind {string}
*/
constructor(name, kind) {
this.column = {
"name": name,
"kind": kind
};
}
/**
*
* @param constraint {ConstraintCardinality|ConstraintDeleteCascade}
* @returns {ColumnBuilder}
*/
addConstraint(constraint) {
_.merge(this.column, {
constraint: constraint
});
return this;
}
/**
*
* @param args {Array.<object|string>} one multi-language object or langtag-value list
* @returns {ColumnBuilder}
*/
displayName(...args) {
this.column.displayName = argumentsToMultiLanguageObj(args);
return this;
}
/**
*
* @param args {Array.<object|string>} one multi-language object or langtag-value list
* @returns {ColumnBuilder}
*/
description(...args) {
this.column.description = argumentsToMultiLanguageObj(args);
return this;
}
/**
*
* @param ordering {number}
* @returns {ColumnBuilder}
*/
ordering(ordering) {
this.column.ordering = ordering;
return this;
}
/**
*
* @param [multilanguage=true] {boolean}
* @returns {ColumnBuilder}
*/
multilanguage(multilanguage) {
if (multilanguage === true || typeof multilanguage === "undefined") {
this.languageType("language");
} else {
this.languageType("neutral");
}
return this;
}
/**
*
* @param languageType {string}
* @param [countryCodes=undefined] {Array.<string>}
* @returns {ColumnBuilder}
*/
languageType(languageType, countryCodes = undefined) {
switch (languageType) {
case "country":
if (!_.isEmpty(languageType) && (!_.isArray(countryCodes) || _.isEmpty(countryCodes))) {
throw new Error("if languageType 'country' argument countryCodes can't be empty", this.column);
}
this.column.languageType = languageType;
this.column.countryCodes = countryCodes;
break;
case "language":
case "neutral":
this.column.languageType = languageType;
break;
default:
throw new Error("invalid languageType for column", this.column);
}
return this;
}
/**
*
* @param [identifier=true] {boolean}
* @returns {ColumnBuilder}
*/
identifier(identifier) {
this.column.identifier = (typeof identifier === "boolean" ? identifier : true);
return this;
}
/**
*
* @param [separator=true] {boolean}
* @returns {ColumnBuilder}
*/
separator(separator) {
this.column.separator = (typeof separator === "boolean" ? separator : true);
return this;
}
/**
*
* @param [hidden=true] {boolean}
* @returns {ColumnBuilder}
*/
hidden(hidden) {
this.column.hidden = (typeof hidden === "boolean" ? hidden : true);
return this;
}
/**
*
* @param maxLength {number}
* @returns {ColumnBuilder}
*/
maxLength(length) {
this.column.maxLength = length;
return this;
}
/**
*
* @param minLength {number}
* @returns {ColumnBuilder}
*/
minLength(length) {
this.column.minLength = length;
return this;
}
/**
*
* @param decimalDigits {number}
* @returns {ColumnBuilder}
*/
decimalDigits(decimalDigits) {
if (this.column.kind !== "numeric") {
throw new Error("column " + this.column.name + " should be of type numeric to set 'decimalDigits(...)'");
}
if (!_.isInteger(decimalDigits) || decimalDigits < 0) {
throw new Error("parameter 'decimalDigits' should be a positive integer number");
}
this.column.decimalDigits = decimalDigits;
return this;
}
/**
*
* @param toTable {Table|number}
* @returns {ColumnBuilder}
*/
simpleLink(toTable) {
this.link(toTable).singleDirection();
return this;
}
/**
*
* @param toTable {Table|number}
* @returns {ColumnBuilder}
*/
link(toTable) {
if (this.column.kind !== "link") {
throw new Error("column " + this.column.name + " should be of type link to set 'link(...)'");
}
let tableId;
if (typeof toTable === "object" && typeof toTable["tableId"] === "number") {
tableId = toTable.tableId;
} else if (typeof toTable === "number") {
tableId = toTable;
} else {
throw new Error("parameter 'toTable' should be an Table object or a number");
}
this.column.toTable = tableId;
return this;
}
/**
*
* @param groups {Array.<number>}
* @returns {ColumnBuilder}
*/
groups(groups) {
if (this.column.kind !== "group") {
throw new Error("column " + this.column.name + " should be of type 'group' to set 'groups(...)'");
}
if (!_.every(groups, _.isInteger)) {
throw new Error("every value of 'groups' parameter should be a positive integer number");
}
this.column.groups = groups;
return this;
}
/**
*
* @param showMemberColumns {boolean}
* @returns {ColumnBuilder}
*/
showMemberColumns(showMemberColumns) {
if (this.column.kind !== "group") {
throw new Error("column " + this.column.name + " should be of type 'group' to set 'showMemberColumns(...)'");
}
this.column.showMemberColumns = _.isBoolean(showMemberColumns) ? showMemberColumns : true;
}
/**
*
* @param formatPattern {string}
* @returns {ColumnBuilder}
*/
formatPattern(formatPattern) {
if (this.column.kind !== "group") {
throw new Error("column " + this.column.name + " should be of type 'group' to set 'groups(...)'");
}
this.column.formatPattern = formatPattern;
return this;
}
/**
*
* @param toName {string}
* @returns {ColumnBuilder}
*/
toName(toName) {
if (this.column.kind !== "link") {
throw new Error("column " + this.column.name + " should be of type link to set 'toName(...)'");
} else if (typeof this.column.singleDirection !== "undefined") {
throw new Error("column " + this.column.name + " can't have 'toName(...)' and 'singleDirection()'");
}
this.column.toName = toName;
return this;
}
/**
*
* @param args {Array.<object|string>} one multi-language object or langtag-value list
* @returns {ColumnBuilder}
*/
toDisplayName(...args) {
if (this.column.kind !== "link") {
throw new Error("column " + this.column.name + " should be of type link to set 'toDisplayName(...)'");
} else if (typeof this.column.singleDirection !== "undefined") {
throw new Error("column " + this.column.name + " can't have 'toDisplayName(...)' and 'singleDirection()'");
}
if (!this.column.toDisplayInfos) {
this.column.toDisplayInfos = {};
}
this.column.toDisplayInfos.displayName = argumentsToMultiLanguageObj(args);
return this;
}
/**
*
* @param args {Array.<object|string>} one multi-language object or langtag-value list
* @returns {ColumnBuilder}
*/
toDescription(...args) {
if (this.column.kind !== "link") {
throw new Error("column " + this.column.name + " should be of type link to set 'toDisplayName(...)'");
} else if (typeof this.column.singleDirection !== "undefined") {
throw new Error("column " + this.column.name + " can't have 'toDisplayName(...)' and 'singleDirection()'");
}
if (!this.column.toDisplayInfos) {
this.column.toDisplayInfos = {};
}
this.column.toDisplayInfos.description = argumentsToMultiLanguageObj(args);
return this;
}
/**
*
* @param toOrdering {number|null}
* @returns {ColumnBuilder}
*/
toOrdering(toOrdering) {
if (this.column.kind !== "link") {
throw new Error("column " + this.column.name + " should be of type link to set 'toOrdering(...)'");
} else if (typeof this.column.singleDirection !== "undefined") {
throw new Error("column " + this.column.name + " can't have 'toOrdering(...)' and 'singleDirection()'");
} else if (!_.isNumber(toOrdering) && toOrdering !== null) {
throw new Error("toOrdering needs to be called with a number or null", this.column);
}
this.column.toOrdering = toOrdering;
return this;
}
/**
*
* @returns {ColumnBuilder}
*/
singleDirection() {
if (this.column.kind !== "link") {
throw new Error("column " + this.column.name + " should be of type link to set 'singleDirection()'");
} else if (typeof this.column.toName !== "undefined") {
throw new Error("column " + this.column.name + " can't have 'singleDirection()' and 'toName(...)'");
}
this.column.singleDirection = true;
return this;
}
build() {
if (typeof this.column.name !== "string" || typeof this.column.kind !== "string") {
throw new Error("at least 'name' (" + this.column.name + ") and 'kind' (" + this.column.kind + ") must be defined");
}
return this.column;
}
}
/**
* @typedef {{cardinality: {from: number, to: number}}} ConstraintCardinality
*/
/**
* @typedef {{deleteCascade: boolean}} ConstraintDeleteCascade
*/
/**
* @typedef {{archiveCascade: boolean}} ConstraintArchiveCascade
*/
/**
* @typedef {{finalCascade: boolean}} ConstraintFinalCascade
*/
/**
*
*/
class ConstraintBuilder {
/**
*
* @returns {ConstraintCardinality}
*/
static cardinalityOneToOne() {
return ConstraintBuilder.cardinality(1, 1);
}
/**
*
* @returns {ConstraintCardinality}
*/
static cardinalityOneToMany() {
return ConstraintBuilder.cardinality(1, 0);
}
/**
*
* @returns {ConstraintCardinality}
*/
static cardinalityManyToOne() {
return ConstraintBuilder.cardinality(0, 1);
}
/**
*
* @param from {number}
* @param to {number}
* @returns {ConstraintCardinality}
*/
static cardinality(from, to) {
return {
cardinality: {
from,
to
}
};
}
/**
*
* @param deleteCascade {boolean}
* @returns {ConstraintDeleteCascade}
*/
static deleteCascade(deleteCascade) {
return {
deleteCascade: _.isBoolean(deleteCascade) ? deleteCascade : true
};
}
/**
*
* @param archiveCascade {boolean}
* @returns {ConstraintArchiveCascade}
*/
static archiveCascade(archiveCascade) {
return {
archiveCascade: _.isBoolean(archiveCascade) ? archiveCascade : true
};
}
/**
*
* @param finalCascade {boolean}
* @returns {ConstraintFinalCascade}
*/
static finalCascade(finalCascade) {
return {
finalCascade: _.isBoolean(finalCascade) ? finalCascade : true
};
}
}
return {
api: syncApi,
asyncApi: asyncApi,
Table: Table,
Tables: Tables,
TableBuilder: TableBuilder,
ColumnBuilder: ColumnBuilder,
ConstraintBuilder: ConstraintBuilder,
StaticHelpers: StaticHelpers
};
}
module.exports = grudStructorizer;