|
|
|
@ -419,62 +419,52 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
|
|
const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
|
|
|
|
|
return await (this as any).execQuery({ $or: orConds }, opts);
|
|
|
|
|
}
|
|
|
|
|
// implicit AND: combine free terms and field:value terms (with or without wildcards)
|
|
|
|
|
const parts = q.split(/\s+/);
|
|
|
|
|
const hasColon = parts.some((t) => t.includes(':'));
|
|
|
|
|
// implicit AND for multiple tokens: free terms, quoted phrases, and field:values
|
|
|
|
|
{
|
|
|
|
|
// Split query into tokens, preserving quoted substrings
|
|
|
|
|
const rawTokens = q.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
|
|
|
// Only apply when more than one token and no boolean operators or grouping
|
|
|
|
|
if (
|
|
|
|
|
parts.length > 1 && hasColon &&
|
|
|
|
|
!q.includes(' AND ') && !q.includes(' OR ') && !q.includes(' NOT ') &&
|
|
|
|
|
!q.includes('(') && !q.includes(')') &&
|
|
|
|
|
!q.includes('[') && !q.includes(']') &&
|
|
|
|
|
!q.includes('"') && !q.includes("'")
|
|
|
|
|
rawTokens.length > 1 &&
|
|
|
|
|
!/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
|
|
|
|
|
!/\[|\]/.test(q)
|
|
|
|
|
) {
|
|
|
|
|
const andConds = parts.map((term) => {
|
|
|
|
|
const m = term.match(/^(\w+):(.+)$/);
|
|
|
|
|
if (m) {
|
|
|
|
|
const field = m[1];
|
|
|
|
|
const value = m[2];
|
|
|
|
|
const andConds: any[] = [];
|
|
|
|
|
for (let token of rawTokens) {
|
|
|
|
|
// field:value token
|
|
|
|
|
const fv = token.match(/^(\w+):(.+)$/);
|
|
|
|
|
if (fv) {
|
|
|
|
|
const field = fv[1];
|
|
|
|
|
let value = fv[2];
|
|
|
|
|
if (!searchableFields.includes(field)) {
|
|
|
|
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
|
|
|
|
}
|
|
|
|
|
// Strip surrounding quotes if present
|
|
|
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
|
|
|
value = value.slice(1, -1);
|
|
|
|
|
}
|
|
|
|
|
// Wildcard search?
|
|
|
|
|
if (value.includes('*') || value.includes('?')) {
|
|
|
|
|
// wildcard field search
|
|
|
|
|
const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
|
|
|
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
|
|
|
return { [field]: { $regex: pattern, $options: 'i' } };
|
|
|
|
|
andConds.push({ [field]: { $regex: pattern, $options: 'i' } });
|
|
|
|
|
} else {
|
|
|
|
|
andConds.push({ [field]: value });
|
|
|
|
|
}
|
|
|
|
|
} else if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
|
|
|
|
// Quoted free phrase across all fields
|
|
|
|
|
const phrase = token.slice(1, -1);
|
|
|
|
|
const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
|
|
|
|
|
const pattern = parts.join('\\s+');
|
|
|
|
|
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })) });
|
|
|
|
|
} else {
|
|
|
|
|
// Free term across all fields
|
|
|
|
|
const esc = escapeForRegex(token);
|
|
|
|
|
andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) });
|
|
|
|
|
}
|
|
|
|
|
// exact field:value
|
|
|
|
|
return { [field]: value };
|
|
|
|
|
}
|
|
|
|
|
// free term -> regex across all searchable fields
|
|
|
|
|
const esc = escapeForRegex(term);
|
|
|
|
|
return { $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) };
|
|
|
|
|
});
|
|
|
|
|
return await (this as any).execQuery({ $and: andConds }, opts);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// free term and quoted field phrase (exact or wildcard), e.g. 'term field:"phrase"' or 'term field:"ph*se"'
|
|
|
|
|
const freeWithQuotedField = q.match(/^(\S+)\s+(\w+):"(.+)"$/);
|
|
|
|
|
if (freeWithQuotedField) {
|
|
|
|
|
const freeTerm = freeWithQuotedField[1];
|
|
|
|
|
const field = freeWithQuotedField[2];
|
|
|
|
|
let phrase = freeWithQuotedField[3];
|
|
|
|
|
if (!searchableFields.includes(field)) {
|
|
|
|
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
|
|
|
|
}
|
|
|
|
|
// free term condition across all searchable fields
|
|
|
|
|
const freeEsc = escapeForRegex(freeTerm);
|
|
|
|
|
const freeCond = { $or: searchableFields.map((f) => ({ [f]: { $regex: freeEsc, $options: 'i' } })) };
|
|
|
|
|
// field condition: exact match or wildcard pattern
|
|
|
|
|
let fieldCond;
|
|
|
|
|
if (phrase.includes('*') || phrase.includes('?')) {
|
|
|
|
|
const escaped = phrase.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
|
|
|
|
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
|
|
|
fieldCond = { [field]: { $regex: pattern, $options: 'i' } };
|
|
|
|
|
} else {
|
|
|
|
|
fieldCond = { [field]: phrase };
|
|
|
|
|
}
|
|
|
|
|
return await (this as any).execQuery({ $and: [freeCond, fieldCond] }, opts);
|
|
|
|
|
}
|
|
|
|
|
// detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
|
|
|
|
|
const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
|
|
|
|
@ -560,12 +550,16 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
|
|
* may lead to data inconsistencies, but is faster
|
|
|
|
|
*/
|
|
|
|
|
public async save() {
|
|
|
|
|
// allow hook before saving
|
|
|
|
|
if (typeof (this as any).beforeSave === 'function') {
|
|
|
|
|
await (this as any).beforeSave();
|
|
|
|
|
}
|
|
|
|
|
// tslint:disable-next-line: no-this-assignment
|
|
|
|
|
const self: any = this;
|
|
|
|
|
let dbResult: any;
|
|
|
|
|
|
|
|
|
|
// update timestamp
|
|
|
|
|
this._updatedAt = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
// perform insert or update
|
|
|
|
|
switch (this.creationStatus) {
|
|
|
|
|
case 'db':
|
|
|
|
|
dbResult = await this.collection.update(self);
|
|
|
|
@ -577,6 +571,10 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
|
|
default:
|
|
|
|
|
console.error('neither new nor in db?');
|
|
|
|
|
}
|
|
|
|
|
// allow hook after saving
|
|
|
|
|
if (typeof (this as any).afterSave === 'function') {
|
|
|
|
|
await (this as any).afterSave();
|
|
|
|
|
}
|
|
|
|
|
return dbResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -584,7 +582,17 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|
|
|
|
* deletes a document from the database
|
|
|
|
|
*/
|
|
|
|
|
public async delete() {
|
|
|
|
|
await this.collection.delete(this);
|
|
|
|
|
// allow hook before deleting
|
|
|
|
|
if (typeof (this as any).beforeDelete === 'function') {
|
|
|
|
|
await (this as any).beforeDelete();
|
|
|
|
|
}
|
|
|
|
|
// perform deletion
|
|
|
|
|
const result = await this.collection.delete(this);
|
|
|
|
|
// allow hook after delete
|
|
|
|
|
if (typeof (this as any).afterDelete === 'function') {
|
|
|
|
|
await (this as any).afterDelete();
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|