@nymphjs/nymph
Version:
Nymph.js - Nymph ORM
1,593 lines (1,519 loc) • 52.2 kB
text/typescript
import fs from 'node:fs';
import { difference } from 'lodash-es';
import ReadLines from 'n-readlines';
import strtotime from 'locutus/php/datetime/strtotime.js';
import { Tokenizer } from '@sciactive/tokenizer';
import type Nymph from '../Nymph.js';
import type { Selector, Options, FormattedSelector } from '../Nymph.types.js';
import type { EntityInstanceType, EntityObjectType } from '../Entity.js';
import type {
EntityConstructor,
EntityData,
EntityInterface,
EntityReference,
SerializedEntityData,
} from '../Entity.types.js';
import {
InvalidParametersError,
UnableToConnectError,
} from '../errors/index.js';
import { xor } from '../utils.js';
// from: https://stackoverflow.com/a/6969486/664915
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
/**
* A Nymph database driver.
*/
export default abstract class NymphDriver {
protected nymph: Nymph = new Proxy({} as Nymph, {
get() {
throw new Error(
'Attempted access of Nymph instance before driver initialization!',
);
},
});
protected tokenizer = new Tokenizer();
/**
* A cache to make entity retrieval faster.
*/
protected entityCache: {
[k: string]: {
cdate: number;
mdate: number;
tags: string[];
data: EntityData;
sdata: SerializedEntityData;
};
} = {};
/**
* A counter for the entity cache to determine the most accessed entities.
*/
protected entityCount: { [k: string]: number } = {};
/**
* Protect against infinite loops.
*/
private putDataCounter = 0;
/**
* This is used internally by Nymph. Don't call it yourself.
*
* @returns A clone of this instance.
*/
abstract clone(): NymphDriver;
/**
* Connect to the data store.
*/
abstract connect(): Promise<boolean>;
abstract isConnected(): boolean;
protected abstract internalTransaction(name: string): Promise<any>;
abstract startTransaction(name: string): Promise<Nymph>;
abstract commit(name: string): Promise<boolean>;
abstract rollback(name: string): Promise<boolean>;
abstract inTransaction(): Promise<boolean>;
abstract deleteEntityByID(
guid: string,
classConstructor: EntityConstructor,
): Promise<boolean>;
abstract deleteEntityByID(guid: string, className?: string): Promise<boolean>;
abstract deleteUID(name: string): Promise<boolean>;
/**
* Disconnect from the data store.
*/
abstract disconnect(): Promise<boolean>;
abstract needsMigration(): Promise<
'json' | 'tokens' | 'tilmeldColumns' | false
>;
abstract liveMigration(
migrationType: 'tokenTables' | 'tilmeldColumns' | 'tilmeldRemoveOldRows',
): Promise<void>;
abstract getEtypes(): Promise<string[]>;
abstract getIndexes(etype: string): Promise<
{
scope: 'data' | 'references' | 'tokens';
name: string;
property: string;
}[]
>;
abstract addIndex(
etype: string,
definition: {
scope: 'data' | 'references' | 'tokens';
name: string;
property: string;
},
): Promise<boolean>;
abstract deleteIndex(
etype: string,
scope: 'data' | 'references' | 'tokens',
name: string,
): Promise<boolean>;
abstract exportDataIterator(): AsyncGenerator<
{ type: 'comment' | 'uid' | 'entity'; content: string },
void,
undefined | false
>;
abstract getEntities<T extends EntityConstructor = EntityConstructor>(
options: Options<T> & { return: 'count' },
...selectors: Selector[]
): Promise<number>;
abstract getEntities<T extends EntityConstructor = EntityConstructor>(
options: Options<T> & { return: 'guid' },
...selectors: Selector[]
): Promise<string[]>;
abstract getEntities<T extends EntityConstructor = EntityConstructor>(
options: Options<T> & { return: 'object' },
...selectors: Selector[]
): Promise<EntityObjectType<T>[]>;
abstract getEntities<T extends EntityConstructor = EntityConstructor>(
options?: Options<T>,
...selectors: Selector[]
): Promise<EntityInstanceType<T>[]>;
abstract getEntities<T extends EntityConstructor = EntityConstructor>(
options?: Options<T>,
...selectors: Selector[]
): Promise<
EntityInstanceType<T>[] | EntityObjectType<T>[] | string[] | number
>;
abstract getUID(name: string): Promise<number | null>;
abstract importEntity(entity: {
guid: string;
cdate: number;
mdate: number;
tags: string[];
sdata: SerializedEntityData;
etype: string;
}): Promise<void>;
abstract importEntityTokens(entity: {
guid: string;
cdate: number;
mdate: number;
tags: string[];
sdata: SerializedEntityData;
etype: string;
}): Promise<void>;
abstract importEntityTilmeldAC(entity: {
guid: string;
cdate: number;
mdate: number;
tags: string[];
sdata: SerializedEntityData;
etype: string;
}): Promise<void>;
abstract importUID(uid: { name: string; value: number }): Promise<void>;
abstract newUID(name: string): Promise<number | null>;
abstract renameUID(oldName: string, newName: string): Promise<boolean>;
abstract saveEntity(entity: EntityInterface): Promise<boolean>;
abstract setUID(name: string, value: number): Promise<boolean>;
/**
* Initialize the Nymph driver.
*
* This is meant to be called internally by Nymph. Don't call this directly.
*
* @param nymph The Nymph instance.
*/
public init(nymph: Nymph) {
this.nymph = nymph;
}
protected posixRegexMatch(
pattern: string,
subject: string,
caseInsensitive = false,
) {
const posixClasses = [
// '[[:<:]]',
// '[[:>:]]',
'[:alnum:]',
'[:alpha:]',
'[:ascii:]',
'[:blank:]',
'[:cntrl:]',
'[:digit:]',
'[:graph:]',
'[:lower:]',
'[:print:]',
'[:punct:]',
'[:space:]',
'[:upper:]',
'[:word:]',
'[:xdigit:]',
];
const jsClasses = [
// '\b(?=\w)',
// '(?<=\w)\b',
'[A-Za-z0-9]',
'[A-Za-z]',
'[\x00-\x7F]',
'[ \t]', // '\s',
'[\x00-\x1F\x7F]', // '[\000\001\002\003\004\005\006\007\008\009\010\011\012\013\014'.
// '\015\016\017\018\019\020\021\022\023\024\025\026\027\028\029'.
// '\030\031\032\033\034\035\036\037\177]',
'[0-9]', // '\d',
'[\x21-\x7E]', // '[A-Za-z0-9!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}\~]',
'[a-z]',
'[\x20-\x7E]', // '[A-Za-z0-9!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}\~]',
'[!"#$%&\'()*+,-./:;<=>?@[\\\\\\]^_‘{|}~]', // '[!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}\~]',
'[ \t\r\n\v\f]', // '[\t\n\x0B\f\r ]',
'[A-Z]',
'[A-Za-z0-9_]',
'[0-9A-Fa-f]',
];
let newPattern = pattern;
for (let i = 0; i < posixClasses.length; i++) {
newPattern = newPattern.replace(posixClasses[i], () => jsClasses[i]);
}
let re = new RegExp(newPattern, caseInsensitive ? 'mi' : 'm');
return re.test(subject);
}
public checkIndexName(name: string) {
if (name.match(/[^a-z0-9_]/)) {
throw new Error(
'Index names can only contain lowercase letters, numbers, and underscores.',
);
}
}
public async export(filename: string) {
try {
const fhandle = fs.openSync(filename, 'w');
if (!fhandle) {
throw new InvalidParametersError('Provided filename is not writeable.');
}
for await (let entry of this.exportDataIterator()) {
fs.writeSync(fhandle, entry.content);
}
fs.closeSync(fhandle);
} catch (e: any) {
return false;
}
return true;
}
public async exportPrint() {
for await (let entry of this.exportDataIterator()) {
console.log(entry.content);
}
return true;
}
public async importDataIterator(
lines: Iterable<string>,
transaction?: boolean,
) {
const first = lines[Symbol.iterator]().next();
if (first.done || first.value !== '#nex2') {
throw new Error('Tried to import a file that is not a NEX v2 file.');
}
if (transaction) {
await this.internalTransaction('nymph-import');
}
try {
let guid: string | null = null;
let sdata: SerializedEntityData = {};
let tags: string[] = [];
let etype = '__undefined';
for (let line of lines) {
if (line.match(/^\s*#/)) {
continue;
}
const entityMatch = line.match(
/^\s*{([0-9A-Fa-f]+)}<([-\w_]+)>\[([^\]]*)\]\s*$/,
);
const propMatch = line.match(/^\s*([^=]+)\s*=\s*(.*\S)\s*$/);
const uidMatch = line.match(/^\s*<([^>]+)>\[(\d+)\]\s*$/);
if (uidMatch) {
// Add the UID.
await this.importUID({
name: uidMatch[1],
value: Number(uidMatch[2]),
});
} else if (entityMatch) {
// Save the current entity.
if (guid) {
const cdate = Number(JSON.parse(sdata.cdate));
delete sdata.cdate;
const mdate = Number(JSON.parse(sdata.mdate));
delete sdata.mdate;
await this.importEntity({ guid, cdate, mdate, tags, sdata, etype });
guid = null;
tags = [];
sdata = {};
etype = '__undefined';
}
// Record the new entity's info.
guid = entityMatch[1];
etype = entityMatch[2];
tags = entityMatch[3].split(',');
} else if (propMatch) {
// Add the variable to the new entity.
if (guid) {
sdata[propMatch[1]] = propMatch[2];
}
}
// Clear the entity cache.
this.entityCache = {};
}
// Save the last entity.
if (guid) {
const cdate = Number(JSON.parse(sdata.cdate));
delete sdata.cdate;
const mdate = Number(JSON.parse(sdata.mdate));
delete sdata.mdate;
await this.importEntity({ guid, cdate, mdate, tags, sdata, etype });
}
if (transaction) {
await this.commit('nymph-import');
}
} catch (e: any) {
await this.rollback('nymph-import');
throw e;
}
return true;
}
public async importData(text: string, transaction?: boolean) {
return await this.importDataIterator(text.split('\n'), transaction);
}
public async import(filename: string, transaction?: boolean) {
let rl: ReadLines;
try {
rl = new ReadLines(filename);
} catch (e: any) {
throw new InvalidParametersError('Provided filename is unreadable.');
}
const lines = {
*[Symbol.iterator]() {
let line: null | Buffer;
while ((line = rl.next())) {
yield line.toString('utf8');
}
},
};
return await this.importDataIterator(lines, transaction);
}
public checkData(
etype: string,
data: EntityData,
sdata: SerializedEntityData,
selectors: Selector[],
guid: string | null = null,
tags: string[] | null = null,
) {
try {
const EntityClass = this.nymph.getEntityClassByEtype(etype);
const formattedSelectors = this.formatSelectors(selectors).selectors;
for (const curSelector of formattedSelectors) {
const type = curSelector.type;
const typeIsNot = type === '!&' || type === '!|';
const typeIsOr = type === '|' || type === '!|';
let pass = !typeIsOr;
for (const k in curSelector) {
const key = k as keyof Selector;
const value = curSelector[key];
if (key === 'type' || value == null) {
continue;
}
const clauseNot = key.substring(0, 1) === '!';
if (key === 'selector' || key === '!selector') {
const tmpArr = (
Array.isArray(value) ? value : [value]
) as Selector[];
pass = xor(
this.checkData(etype, data, sdata, tmpArr, guid, tags),
xor(typeIsNot, clauseNot),
);
} else {
if (key === 'qref' || key === '!qref') {
throw new Error("Can't use checkData on qref clauses.");
} else {
// Check if it doesn't pass any for &, check if it passes any for |.
for (const curValue of value) {
if (curValue == null) {
continue;
}
const propName = (curValue as string[])[0];
// Unserialize the data for this variable.
if (propName in sdata) {
data[propName] = JSON.parse(sdata[propName]);
delete sdata[propName];
}
switch (key) {
case 'guid':
case '!guid':
pass = xor(guid == propName, xor(typeIsNot, clauseNot));
break;
case 'tag':
case '!tag':
pass = xor(
(tags ?? []).indexOf(propName) !== -1,
xor(typeIsNot, clauseNot),
);
break;
case 'defined':
case '!defined':
pass = xor(propName in data, xor(typeIsNot, clauseNot));
break;
case 'truthy':
case '!truthy':
pass = xor(
propName in data && data[propName],
xor(typeIsNot, clauseNot),
);
break;
case 'ref':
case '!ref':
const testRefValue = (curValue as [string, any])[1] as any;
pass = xor(
propName in data &&
this.entityReferenceSearch(
data[propName],
testRefValue,
),
xor(typeIsNot, clauseNot),
);
break;
case 'equal':
case '!equal':
const testEqualValue = (
curValue as [string, any]
)[1] as any;
pass = xor(
propName in data &&
JSON.stringify(data[propName]) ===
JSON.stringify(testEqualValue),
xor(typeIsNot, clauseNot),
);
break;
case 'contain':
case '!contain':
const testContainValue = (
curValue as [string, any]
)[1] as any;
pass = xor(
propName in data &&
JSON.stringify(data[propName]).indexOf(
JSON.stringify(testContainValue),
) !== -1,
xor(typeIsNot, clauseNot),
);
break;
case 'like':
case '!like':
const testLikeValue = (
curValue as [string, string]
)[1] as string;
pass = xor(
propName in data &&
new RegExp(
'^' +
escapeRegExp(testLikeValue)
.replace('%', () => '.*')
.replace('_', () => '.') +
'$',
).test(data[propName]),
xor(typeIsNot, clauseNot),
);
break;
case 'ilike':
case '!ilike':
const testILikeValue = (
curValue as [string, string]
)[1] as string;
pass = xor(
propName in data &&
new RegExp(
'^' +
escapeRegExp(testILikeValue)
.replace('%', () => '.*')
.replace('_', () => '.') +
'$',
'i',
).test(data[propName]),
xor(typeIsNot, clauseNot),
);
break;
case 'match':
case '!match':
const testMatchValue = (
curValue as [string, string]
)[1] as string;
// Convert a POSIX regex to a JS regex.
pass = xor(
propName in data &&
this.posixRegexMatch(testMatchValue, data[propName]),
xor(typeIsNot, clauseNot),
);
break;
case 'imatch':
case '!imatch':
const testIMatchValue = (
curValue as [string, string]
)[1] as string;
// Convert a POSIX regex to a JS regex.
pass = xor(
propName in data &&
this.posixRegexMatch(
testIMatchValue,
data[propName],
true,
),
xor(typeIsNot, clauseNot),
);
break;
case 'search':
case '!search':
const testSearchValue = (
curValue as [string, string]
)[1] as string;
const tokenString =
propName in data
? (EntityClass.getFTSText(propName, data[propName]) ??
'')
: '';
pass = xor(
propName in data &&
this.tokenizer.searchString(
testSearchValue,
tokenString,
),
xor(typeIsNot, clauseNot),
);
break;
case 'gt':
case '!gt':
const testGTValue = (
curValue as [string, number]
)[1] as number;
pass = xor(
propName in data && data[propName] > testGTValue,
xor(typeIsNot, clauseNot),
);
break;
case 'gte':
case '!gte':
const testGTEValue = (
curValue as [string, number]
)[1] as number;
pass = xor(
propName in data && data[propName] >= testGTEValue,
xor(typeIsNot, clauseNot),
);
break;
case 'lt':
case '!lt':
const testLTValue = (
curValue as [string, number]
)[1] as number;
pass = xor(
propName in data && data[propName] < testLTValue,
xor(typeIsNot, clauseNot),
);
break;
case 'lte':
case '!lte':
const testLTEValue = (
curValue as [string, number]
)[1] as number;
pass = xor(
propName in data && data[propName] <= testLTEValue,
xor(typeIsNot, clauseNot),
);
break;
}
if (!xor(typeIsOr, pass)) {
break;
}
}
}
}
if (!xor(typeIsOr, pass)) {
break;
}
}
if (!pass) {
return false;
}
}
return true;
} catch (e: any) {
this.nymph.config.debugError(
'nymph',
`Failed to check entity data: ${e}`,
);
throw e;
}
}
/**
* Remove all copies of an entity from the cache.
*
* @param guid The GUID of the entity to remove.
*/
protected cleanCache(guid: string) {
delete this.entityCache[guid];
}
public async deleteEntity(entity: EntityInterface) {
const className = (entity.constructor as any).class as string;
if (entity.guid == null) {
return false;
}
const ret = await this.deleteEntityByID(entity.guid, className);
if (ret) {
entity.guid = null;
entity.cdate = null;
entity.mdate = null;
}
return ret;
}
/**
* Search through a value for an entity reference.
*
* @param value Any value to search.
* @param entity An entity, GUID, or array of either to search for.
* @returns True if the reference is found, false otherwise.
*/
protected entityReferenceSearch(
value: any,
entity:
| EntityInterface
| EntityReference
| string
| (EntityInterface | EntityReference | string)[],
) {
// Get the GUID, if the passed $entity is an object.
const guids: string[] = [];
if (Array.isArray(entity)) {
if (entity[0] === 'nymph_entity_reference') {
guids.push(value[1]);
} else {
for (const curEntity of entity) {
if (typeof curEntity === 'string') {
guids.push(curEntity);
} else if (Array.isArray(curEntity)) {
guids.push(curEntity[1]);
} else if (typeof curEntity.guid === 'string') {
guids.push(curEntity.guid);
}
}
}
} else if (typeof entity === 'string') {
guids.push(entity);
} else if (typeof entity.guid === 'string') {
guids.push(entity.guid);
}
if (
Array.isArray(value) &&
value[0] === 'nymph_entity_reference' &&
guids.indexOf(value[1]) !== -1
) {
return true;
}
// Search through arrays and objects looking for the reference.
if (value != null && (Array.isArray(value) || typeof value === 'object')) {
for (const key in value) {
if (this.entityReferenceSearch(value[key], guids)) {
return true;
}
}
}
return false;
}
protected removeAndReturnACValues(
etype: string,
data: EntityData,
sdata: SerializedEntityData,
) {
let user: string | null = null;
let group: string | null = null;
let acUser: number | null = null;
let acGroup: number | null = null;
let acOther: number | null = null;
let acRead: string[] | null = null;
let acWrite: string[] | null = null;
let acFull: string[] | null = null;
if (this.nymph.tilmeld != null) {
// Since Tilmeld is installed, we need to fill the AC vars.
const userValue =
'user' in sdata
? JSON.parse(sdata.user)
: 'user' in data
? data.user
: null;
if (
Array.isArray(userValue) &&
userValue[0] === 'nymph_entity_reference' &&
userValue[2] === 'User' &&
typeof userValue[1] === 'string' &&
userValue[1].match(/^[0-9a-f]{24}$/i)
) {
user = userValue[1];
}
const groupValue =
'group' in sdata
? JSON.parse(sdata.group)
: 'group' in data
? data.group
: null;
if (
Array.isArray(groupValue) &&
groupValue[0] === 'nymph_entity_reference' &&
groupValue[2] === 'Group' &&
typeof groupValue[1] === 'string' &&
groupValue[1].match(/^[0-9a-f]{24}$/i)
) {
group = groupValue[1];
}
if (etype !== 'tilmeld_user' && etype !== 'tilmeld_group') {
const acUserValue =
'acUser' in sdata
? JSON.parse(sdata.acUser)
: 'acUser' in data
? data.acUser
: null;
if (
typeof acUserValue === 'number' &&
!isNaN(acUserValue) &&
acUserValue >= 0
) {
acUser = acUserValue;
}
const acGroupValue =
'acGroup' in sdata
? JSON.parse(sdata.acGroup)
: 'acGroup' in data
? data.acGroup
: null;
if (
typeof acGroupValue === 'number' &&
!isNaN(acGroupValue) &&
acGroupValue >= 0
) {
acGroup = acGroupValue;
}
const acOtherValue =
'acOther' in sdata
? JSON.parse(sdata.acOther)
: 'acOther' in data
? data.acOther
: null;
if (
typeof acOtherValue === 'number' &&
!isNaN(acOtherValue) &&
acOtherValue >= 0
) {
acOther = acOtherValue;
}
const acReadValue =
'acRead' in sdata
? JSON.parse(sdata.acRead)
: 'acRead' in data
? data.acRead
: null;
if (Array.isArray(acReadValue)) {
const acReadArray = [];
for (let acReadEntry of acReadValue) {
if (
typeof acReadEntry === 'string' &&
acReadEntry.match(/^[0-9a-f]{24}$/i)
) {
acReadArray.push(acReadEntry);
} else if (
Array.isArray(acReadEntry) &&
acReadEntry[0] === 'nymph_entity_reference' &&
(acReadEntry[2] === 'User' || acReadEntry[2] === 'Group') &&
typeof acReadEntry[1] === 'string' &&
acReadEntry[1].match(/^[0-9a-f]{24}$/i)
) {
acReadArray.push(acReadEntry[1]);
}
}
if (acReadArray.length) {
acRead = acReadArray;
}
}
const acWriteValue =
'acWrite' in sdata
? JSON.parse(sdata.acWrite)
: 'acWrite' in data
? data.acWrite
: null;
if (Array.isArray(acWriteValue)) {
const acWriteArray = [];
for (let acWriteEntry of acWriteValue) {
if (
typeof acWriteEntry === 'string' &&
acWriteEntry.match(/^[0-9a-f]{24}$/i)
) {
acWriteArray.push(acWriteEntry);
} else if (
Array.isArray(acWriteEntry) &&
acWriteEntry[0] === 'nymph_entity_reference' &&
(acWriteEntry[2] === 'User' || acWriteEntry[2] === 'Group') &&
typeof acWriteEntry[1] === 'string' &&
acWriteEntry[1].match(/^[0-9a-f]{24}$/i)
) {
acWriteArray.push(acWriteEntry[1]);
}
}
if (acWriteArray.length) {
acWrite = acWriteArray;
}
}
const acFullValue =
'acFull' in sdata
? JSON.parse(sdata.acFull)
: 'acFull' in data
? data.acFull
: null;
if (Array.isArray(acFullValue)) {
const acFullArray = [];
for (let acFullEntry of acFullValue) {
if (
typeof acFullEntry === 'string' &&
acFullEntry.match(/^[0-9a-f]{24}$/i)
) {
acFullArray.push(acFullEntry);
} else if (
Array.isArray(acFullEntry) &&
acFullEntry[0] === 'nymph_entity_reference' &&
(acFullEntry[2] === 'User' || acFullEntry[2] === 'Group') &&
typeof acFullEntry[1] === 'string' &&
acFullEntry[1].match(/^[0-9a-f]{24}$/i)
) {
acFullArray.push(acFullEntry[1]);
}
}
if (acFullArray.length) {
acFull = acFullArray;
}
}
}
// Delete all of the data, so it doesn't go into the database as rows in
// the data tables.
delete sdata.user;
delete data.user;
delete sdata.group;
delete data.group;
delete sdata.acUser;
delete data.acUser;
delete sdata.acGroup;
delete data.acGroup;
delete sdata.acOther;
delete data.acOther;
delete sdata.acRead;
delete data.acRead;
delete sdata.acWrite;
delete data.acWrite;
delete sdata.acFull;
delete data.acFull;
}
return {
user,
group,
acUser,
acGroup,
acOther,
acRead,
acWrite,
acFull,
};
}
public formatSelectors(
selectors: Selector[],
options: Options = {},
): {
selectors: FormattedSelector[];
qrefs: [Options, ...FormattedSelector[]][];
} {
const newSelectors: FormattedSelector[] = [];
const qrefs: [Options, ...FormattedSelector[]][] = [];
for (const curSelector of selectors) {
const newSelector: FormattedSelector = {
type: curSelector.type,
};
for (const k in curSelector) {
const key = k as keyof Selector;
const value = curSelector[key];
if (key === 'type') {
continue;
}
if (value === undefined) {
continue;
}
if (key === 'qref' || key === '!qref') {
const tmpArr = (
Array.isArray(((value as Selector['qref']) ?? [])[0])
? value
: [value]
) as [string, [Options, ...Selector[]]][];
const formatArr: [string, [Options, ...FormattedSelector[]]][] = [];
for (let i = 0; i < tmpArr.length; i++) {
const name = tmpArr[i][0];
const [qrefOptions, ...qrefSelectors] = tmpArr[i][1];
const QrefEntityClass = qrefOptions.class
? this.nymph.getEntityClass(qrefOptions.class)
: this.nymph.getEntityClass('Entity');
const newOptions = {
...qrefOptions,
class: QrefEntityClass,
source: options.source,
};
const newSelectors = this.formatSelectors(qrefSelectors, options);
qrefs.push(
[newOptions, ...newSelectors.selectors],
...newSelectors.qrefs,
);
formatArr[i] = [name, [newOptions, ...newSelectors.selectors]];
}
newSelector[key] = formatArr;
} else if (key === 'selector' || key === '!selector') {
const tmpArr = (Array.isArray(value) ? value : [value]) as Selector[];
const newSelectors = this.formatSelectors(tmpArr, options);
newSelector[key] = newSelectors.selectors;
qrefs.push(...newSelectors.qrefs);
} else if (!Array.isArray(value)) {
// @ts-ignore: ts doesn't know what value is here.
newSelector[key] = [[value]];
} else if (!Array.isArray(value[0])) {
if (
value.length === 3 &&
value[1] == null &&
typeof value[2] === 'string'
) {
// @ts-ignore: ts doesn't know what value is here.
newSelector[key] = [[value[0], strtotime(value[2]) * 1000]];
} else {
// @ts-ignore: ts doesn't know what value is here.
newSelector[key] = [value];
}
} else {
// @ts-ignore: ts doesn't know what value is here.
newSelector[key] = value.map((curValue) => {
if (
curValue.length === 3 &&
curValue[1] == null &&
typeof curValue[2] === 'string'
) {
return [curValue[0], strtotime(curValue[2]) * 1000];
}
return curValue;
});
}
}
newSelectors.push(newSelector);
}
return { selectors: newSelectors, qrefs };
}
protected iterateSelectorsForQuery(
selectors: FormattedSelector[],
callback: (data: {
key: string;
value: any;
typeIsOr: boolean;
typeIsNot: boolean;
}) => string,
) {
const queryParts = [];
for (const curSelector of selectors) {
let curSelectorQuery = '';
let type: string = curSelector.type;
let typeIsNot: boolean = type === '!&' || type === '!|';
let typeIsOr: boolean = type === '|' || type === '!|';
for (const key in curSelector) {
const value: any = (curSelector as any)[key];
if (key === 'type') {
continue;
}
let curQuery = callback({ key, value, typeIsOr, typeIsNot });
if (curQuery) {
if (curSelectorQuery) {
curSelectorQuery += typeIsOr ? ' OR ' : ' AND ';
}
curSelectorQuery += curQuery;
}
}
if (curSelectorQuery) {
queryParts.push(curSelectorQuery);
}
}
return queryParts;
}
protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
options: Options<T> & { return: 'count' },
selectors: Selector[],
performQueryCallback: (data: {
options: Options<T>;
selectors: FormattedSelector[];
etype: string;
}) => {
result: any;
},
rowFetchCallback: () => any,
freeResultCallback: () => void,
getCountCallback: (row: any) => number,
getGUIDCallback: (row: any) => string,
getTagsAndDatesCallback: (row: any) => {
tags: string[];
cdate: number;
mdate: number;
},
getDataNameAndSValueCallback: (row: any) => {
name: string;
svalue: string;
},
): { result: any; process: () => number | Error };
protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
options: Options<T> & { return: 'guid' },
selectors: Selector[],
performQueryCallback: (data: {
options: Options<T>;
selectors: FormattedSelector[];
etype: string;
}) => {
result: any;
},
rowFetchCallback: () => any,
freeResultCallback: () => void,
getCountCallback: (row: any) => number,
getGUIDCallback: (row: any) => string,
getTagsAndDatesCallback: (row: any) => {
tags: string[];
cdate: number;
mdate: number;
},
getDataNameAndSValueCallback: (row: any) => {
name: string;
svalue: string;
},
): { result: any; process: () => string[] | Error };
protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
options: Options<T> & { return: 'object' },
selectors: Selector[],
performQueryCallback: (data: {
options: Options<T>;
selectors: FormattedSelector[];
etype: string;
}) => {
result: any;
},
rowFetchCallback: () => any,
freeResultCallback: () => void,
getCountCallback: (row: any) => number,
getGUIDCallback: (row: any) => string,
getTagsAndDatesCallback: (row: any) => {
tags: string[];
cdate: number;
mdate: number;
},
getDataNameAndSValueCallback: (row: any) => {
name: string;
svalue: string;
},
): { result: any; process: () => EntityObjectType<T>[] | Error };
protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
options: Options<T>,
selectors: Selector[],
performQueryCallback: (data: {
options: Options<T>;
selectors: FormattedSelector[];
etype: string;
}) => {
result: any;
},
rowFetchCallback: () => any,
freeResultCallback: () => void,
getCountCallback: (row: any) => number,
getGUIDCallback: (row: any) => string,
getTagsAndDatesCallback: (row: any) => {
tags: string[];
cdate: number;
mdate: number;
},
getDataNameAndSValueCallback: (row: any) => {
name: string;
svalue: string;
},
): {
result: any;
process: () => EntityInstanceType<T>[] | Error;
};
protected getEntitiesRowLike<T extends EntityConstructor = EntityConstructor>(
options: Options<T>,
selectors: Selector[],
performQueryCallback: (data: {
options: Options<T>;
selectors: FormattedSelector[];
etype: string;
}) => {
result: any;
},
rowFetchCallback: () => any,
freeResultCallback: () => void,
getCountCallback: (row: any) => number,
getGUIDCallback: (row: any) => string,
getTagsAndDatesCallback: (row: any) => {
tags: string[];
cdate: number;
mdate: number;
user?: string;
group?: string;
acUser?: number;
acGroup?: number;
acOther?: number;
acRead?: string[];
acWrite?: string[];
acFull?: string[];
},
getDataNameAndSValueCallback: (row: any) => {
name: string;
svalue: string;
},
): {
result: any;
process: () =>
| EntityInstanceType<T>[]
| EntityObjectType<T>[]
| string[]
| number
| Error;
} {
if (!this.isConnected()) {
throw new UnableToConnectError('not connected to DB');
}
for (const selector of selectors) {
if (
!selector ||
Object.keys(selector).length === 1 ||
!('type' in selector) ||
['&', '!&', '|', '!|'].indexOf(selector.type) === -1
) {
throw new InvalidParametersError(
'Invalid query selector passed: ' + JSON.stringify(selector),
);
}
}
let entities: EntityInstanceType<T>[] | EntityObjectType<T>[] | string[] =
[];
let count = 0;
const EntityClass =
options.class ?? (this.nymph.getEntityClass('Entity') as T);
const etype = EntityClass.ETYPE;
if (options.source === 'client' && options.return === 'object') {
throw new InvalidParametersError(
'Object return type not allowed from client.',
);
}
// Check if the requested entity is cached.
if (
this.nymph.config.cache &&
!options.skipCache &&
selectors.length &&
'guid' in selectors[0] &&
typeof selectors[0].guid === 'number'
) {
// Only safe to use the cache option with no other selectors than a GUID
// and tags.
if (
selectors.length === 1 &&
selectors[0].type === '&' &&
(Object.keys(selectors[0]).length === 2 ||
(Object.keys(selectors[0]).length === 3 && 'tag' in selectors[0]))
) {
const cacheEntity = this.pullCache(selectors[0]['guid']);
if (
cacheEntity != null &&
cacheEntity.guid != null &&
(!('tag' in selectors[0]) ||
!(
Array.isArray(selectors[0].tag)
? selectors[0].tag
: [selectors[0].tag]
).find((tag) => tag == null || !cacheEntity.tags.includes(tag)))
) {
// Return the value from the cache.
const { guid, cdate, mdate, tags, data, sdata } = cacheEntity;
if (options.return === 'count') {
return {
result: Promise.resolve(null),
process: () => 1,
};
} else if (options.return === 'guid') {
return {
result: Promise.resolve(null),
process: () => [guid],
};
} else if (options.return === 'object') {
return {
result: Promise.resolve(null),
process: () => [
{
guid,
cdate,
mdate,
tags,
...data,
...Object.fromEntries(
Object.entries(sdata).map(([name, value]) => [
name,
JSON.parse(value),
]),
),
} as EntityObjectType<T>,
],
};
} else {
const entity = this.nymph
.getEntityClass(EntityClass.class)
.factorySync() as EntityInstanceType<T>;
if (entity) {
entity.$nymph = this.nymph;
if (options.skipAc != null) {
entity.$useSkipAc(!!options.skipAc);
}
entity.guid = guid;
entity.cdate = cdate;
entity.mdate = mdate;
entity.tags = tags;
entity.$putData(data, sdata, 'server');
return {
result: Promise.resolve(null),
process: () => [entity],
};
}
return {
result: Promise.resolve(null),
process: () => [],
};
}
}
}
}
try {
const formattedSelectors = this.formatSelectors(selectors, options);
this.nymph.runQueryCallbacks(options, formattedSelectors.selectors);
for (let i = 0; i < formattedSelectors.qrefs.length; i++) {
const [options, ...selectors] = formattedSelectors.qrefs[i];
this.nymph.runQueryCallbacks(options, selectors);
formattedSelectors.qrefs[i] = [options, ...selectors];
}
const { result } = performQueryCallback({
options,
selectors: formattedSelectors.selectors,
etype,
});
return {
result,
process: () => {
let row = rowFetchCallback();
if (options.return === 'count') {
while (row != null) {
count += getCountCallback(row);
row = rowFetchCallback();
}
} else if (options.return === 'guid') {
while (row != null) {
(entities as string[]).push(getGUIDCallback(row));
row = rowFetchCallback();
}
} else {
while (row != null) {
const guid = getGUIDCallback(row);
const tagsAndDates = getTagsAndDatesCallback(row);
const tags = tagsAndDates.tags;
const cdate = tagsAndDates.cdate;
const mdate = tagsAndDates.mdate;
let dataNameAndSValue = getDataNameAndSValueCallback(row);
// Data.
const data: EntityData = {};
// Serialized data.
const sdata: SerializedEntityData = {};
if (dataNameAndSValue.name !== '') {
// This do will keep going and adding the data until the
// next entity is reached. $row will end on the next entity.
do {
dataNameAndSValue = getDataNameAndSValueCallback(row);
sdata[dataNameAndSValue.name] = dataNameAndSValue.svalue;
row = rowFetchCallback();
} while (row != null && getGUIDCallback(row) === guid);
} else {
// Make sure that $row is incremented :)
row = rowFetchCallback();
}
if (this.nymph.tilmeld) {
if ('user' in tagsAndDates && tagsAndDates.user != null) {
data.user = [
'nymph_entity_reference',
tagsAndDates.user,
'User',
];
delete sdata.user;
}
if ('group' in tagsAndDates && tagsAndDates.group != null) {
data.group = [
'nymph_entity_reference',
tagsAndDates.group,
'Group',
];
delete sdata.group;
}
if ('acUser' in tagsAndDates && tagsAndDates.acUser != null) {
data.acUser = tagsAndDates.acUser;
delete sdata.acUser;
}
if ('acGroup' in tagsAndDates && tagsAndDates.acGroup != null) {
data.acGroup = tagsAndDates.acGroup;
delete sdata.acGroup;
}
if ('acOther' in tagsAndDates && tagsAndDates.acOther != null) {
data.acOther = tagsAndDates.acOther;
delete sdata.acOther;
}
if ('acRead' in tagsAndDates && tagsAndDates.acRead != null) {
data.acRead = tagsAndDates.acRead;
delete sdata.acRead;
}
if ('acWrite' in tagsAndDates && tagsAndDates.acWrite != null) {
data.acWrite = tagsAndDates.acWrite;
delete sdata.acWrite;
}
if ('acFull' in tagsAndDates && tagsAndDates.acFull != null) {
data.acFull = tagsAndDates.acFull;
delete sdata.acFull;
}
}
if (options.return === 'object') {
(entities as EntityObjectType<T>[]).push({
guid,
cdate,
mdate,
tags,
...data,
...Object.fromEntries(
Object.entries(sdata).map(([name, value]) => [
name,
JSON.parse(value),
]),
),
} as EntityObjectType<T>);
} else {
const entity =
EntityClass.factorySync() as EntityInstanceType<T>;
entity.$nymph = this.nymph;
if (options.skipAc != null) {
entity.$useSkipAc(!!options.skipAc);
}
entity.guid = guid;
entity.cdate = cdate;
entity.mdate = mdate;
entity.tags = tags;
this.putDataCounter++;
if (this.putDataCounter == 100) {
throw new Error('Infinite loop detected in Entity loading.');
}
entity.$putData(data, sdata, 'server');
this.putDataCounter--;
entities.push(entity);
}
if (this.nymph.config.cache) {
this.pushCache(guid, cdate, mdate, tags, data, sdata);
}
}
}
freeResultCallback();
return options.return === 'count' ? count : entities;
},
};
} catch (e: any) {
return {
result: Promise.resolve(e),
process: () => {
return e;
},
};
}
}
protected async saveEntityRowLike(
entity: EntityInterface,
saveNewEntityCallback: (data: {
entity: EntityInterface;
guid: string;
tags: string[];
data: EntityData;
sdata: SerializedEntityData;
uniques: string[];
cdate: number;
etype: string;
}) => Promise<boolean>,
saveExistingEntityCallback: (data: {
entity: EntityInterface;
guid: string;
tags: string[];
data: EntityData;
sdata: SerializedEntityData;
uniques: string[];
mdate: number;
etype: string;
}) => Promise<boolean>,
startTransactionCallback: (() => Promise<void>) | null = null,
commitTransactionCallback:
| ((success: boolean) => Promise<boolean>)
| null = null,
) {
const originalGuid = entity.guid;
const originalCdate = entity.cdate;
const originalMdate = entity.mdate;
// Get a modified date.
let mdate = Date.now();
if (entity.mdate != null) {
if (this.nymph.config.updateMDate === false) {
mdate = entity.mdate;
} else if (typeof this.nymph.config.updateMDate === 'number') {
mdate = entity.mdate + this.nymph.config.updateMDate;
} else if (entity.mdate >= mdate) {
// If we're saving too fast (within 1 millisecond), increment mdate to
// make sure it doesn't conflict in the DB.
mdate = entity.mdate + 1;
}
}
const tags = difference(entity.tags, ['']);
const data = entity.$getData();
const sdata = entity.$getSData();
const uniques = await entity.$getUniques();
const EntityClass = entity.constructor as EntityConstructor;
const etype = EntityClass.ETYPE;
if (startTransactionCallback) {
await startTransactionCallback();
}
let success = false;
try {
if (entity.guid == null) {
const cdate = mdate;
const newId = entity.$getGuaranteedGUID();
success = await saveNewEntityCallback({
entity,
guid: newId,
tags,
data,
sdata,
uniques,
cdate,
etype,
});
if (success) {
entity.guid = newId;
entity.cdate = cdate;
entity.mdate = mdate;
}
} else {
// Removed any cached versions of this entity.
if (this.nymph.config.cache) {
this.cleanCache(entity.guid);
}
success = await saveExistingEntityCallback({
entity,
guid: entity.guid,
tags,
data,
sdata,
uniques,
mdate,
etype,
});
if (success) {
entity.mdate = mdate;
}
}
if (commitTransactionCallback) {
success = await commitTransactionCallback(success);
}
// Cache the entity.
if (success && this.nymph.config.cache) {
this.pushCache(
entity.guid as string,
entity.cdate as number,
entity.mdate as number,
entity.tags,
data,
sdata,
);
}
} catch (e: any) {
entity.guid = originalGuid;
entity.cdate = originalCdate;
entity.mdate = originalMdate;
throw e;
}
if (!success) {
entity.guid = originalGuid;
entity.cdate = originalCdate;
entity.mdate = originalMdate;
}
return success;
}
/**
* Pull an entity from the cache.
*
* @param guid The entity's GUID.
* @returns The entity's data or null if it's not cached.
*/
protected pullCache(guid: string): {
guid: string;
cdate: number;
mdate: number;
tags: string[];
data: EntityData;
sdata: SerializedEnt