fix(doc): Refactor searchable fields API and improve collection registration.
This commit is contained in:
parent
eef758cabb
commit
d0e769622e
@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-04-21 - 5.11.1 - fix(doc)
|
||||||
|
Refactor searchable fields API and improve collection registration.
|
||||||
|
|
||||||
|
- Removed the standalone getSearchableFields utility in favor of a static method on document classes.
|
||||||
|
- Updated tests to use the new static method (e.g., Product.getSearchableFields()).
|
||||||
|
- Ensured the Collection decorator attaches a docCtor property to correctly register searchable fields.
|
||||||
|
- Added try/catch in test cleanup to gracefully handle dropDatabase errors.
|
||||||
|
|
||||||
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
|
## 2025-04-21 - 5.11.0 - feat(ts/classes.lucene.adapter)
|
||||||
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
|
Expose luceneWildcardToRegex method to allow external usage and enhance regex transformation capabilities.
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
import * as smartmongo from '@push.rocks/smartmongo';
|
import * as smartmongo from '@push.rocks/smartmongo';
|
||||||
import * as smartdata from '../ts/index.js';
|
import * as smartdata from '../ts/index.js';
|
||||||
import { searchable, getSearchableFields } from '../ts/classes.doc.js';
|
import { searchable } from '../ts/classes.doc.js';
|
||||||
import { smartunique } from '../ts/plugins.js';
|
import { smartunique } from '../ts/plugins.js';
|
||||||
|
|
||||||
// Set up database connection
|
// Set up database connection
|
||||||
|
@ -4,7 +4,7 @@ import { smartunique } from '../ts/plugins.js';
|
|||||||
|
|
||||||
// Import the smartdata library
|
// Import the smartdata library
|
||||||
import * as smartdata from '../ts/index.js';
|
import * as smartdata from '../ts/index.js';
|
||||||
import { searchable, getSearchableFields } from '../ts/classes.doc.js';
|
import { searchable } from '../ts/classes.doc.js';
|
||||||
|
|
||||||
// Set up database connection
|
// Set up database connection
|
||||||
let smartmongoInstance: smartmongo.SmartMongo;
|
let smartmongoInstance: smartmongo.SmartMongo;
|
||||||
@ -72,7 +72,7 @@ tap.test('should create test products with searchable fields', async () => {
|
|||||||
|
|
||||||
tap.test('should retrieve searchable fields for a class', async () => {
|
tap.test('should retrieve searchable fields for a class', async () => {
|
||||||
// Use the getSearchableFields function to verify our searchable fields
|
// Use the getSearchableFields function to verify our searchable fields
|
||||||
const searchableFields = getSearchableFields('Product');
|
const searchableFields = Product.getSearchableFields();
|
||||||
console.log('Searchable fields:', searchableFields);
|
console.log('Searchable fields:', searchableFields);
|
||||||
|
|
||||||
expect(searchableFields.length).toEqual(3);
|
expect(searchableFields.length).toEqual(3);
|
||||||
|
@ -64,7 +64,11 @@ tap.test('should watch a collection', async (toolsArg) => {
|
|||||||
// close the database connection
|
// close the database connection
|
||||||
// =======================================
|
// =======================================
|
||||||
tap.test('close', async () => {
|
tap.test('close', async () => {
|
||||||
await testDb.mongoDb.dropDatabase();
|
try {
|
||||||
|
await testDb.mongoDb.dropDatabase();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('dropDatabase error ignored in cleanup:', err.message || err);
|
||||||
|
}
|
||||||
await testDb.close();
|
await testDb.close();
|
||||||
if (smartmongoInstance) {
|
if (smartmongoInstance) {
|
||||||
await smartmongoInstance.stop();
|
await smartmongoInstance.stop();
|
||||||
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartdata',
|
name: '@push.rocks/smartdata',
|
||||||
version: '5.11.0',
|
version: '5.11.1',
|
||||||
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
description: 'An advanced library for NoSQL data organization and manipulation using TypeScript with support for MongoDB, data validation, collections, and custom data types.'
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { SmartdataDb } from './classes.db.js';
|
import { SmartdataDb } from './classes.db.js';
|
||||||
import { SmartdataDbCursor } from './classes.cursor.js';
|
import { SmartdataDbCursor } from './classes.cursor.js';
|
||||||
import { SmartDataDbDoc, type IIndexOptions, getSearchableFields } from './classes.doc.js';
|
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
|
||||||
import { SmartdataDbWatcher } from './classes.watcher.js';
|
import { SmartdataDbWatcher } from './classes.watcher.js';
|
||||||
import { CollectionFactory } from './classes.collectionfactory.js';
|
import { CollectionFactory } from './classes.collectionfactory.js';
|
||||||
|
|
||||||
@ -32,13 +32,22 @@ export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
|
|||||||
if (!(dbArg instanceof SmartdataDb)) {
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
dbArg = dbArg();
|
dbArg = dbArg();
|
||||||
}
|
}
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||||
|
// Attach document constructor for searchableFields lookup
|
||||||
|
if (!(coll as any).docCtor) {
|
||||||
|
(coll as any).docCtor = decoratedClass;
|
||||||
|
}
|
||||||
|
return coll;
|
||||||
}
|
}
|
||||||
public get collection() {
|
public get collection() {
|
||||||
if (!(dbArg instanceof SmartdataDb)) {
|
if (!(dbArg instanceof SmartdataDb)) {
|
||||||
dbArg = dbArg();
|
dbArg = dbArg();
|
||||||
}
|
}
|
||||||
return collectionFactory.getCollection(constructor.name, dbArg);
|
const coll = collectionFactory.getCollection(constructor.name, dbArg);
|
||||||
|
if (!(coll as any).docCtor) {
|
||||||
|
(coll as any).docCtor = decoratedClass;
|
||||||
|
}
|
||||||
|
return coll;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return decoratedClass;
|
return decoratedClass;
|
||||||
@ -156,7 +165,9 @@ export class SmartdataCollection<T> {
|
|||||||
}
|
}
|
||||||
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
|
||||||
// Auto-create a compound text index on all searchable fields
|
// Auto-create a compound text index on all searchable fields
|
||||||
const searchableFields = getSearchableFields(this.collectionName);
|
// Use document constructor's searchableFields registered via decorator
|
||||||
|
const docCtor = (this as any).docCtor;
|
||||||
|
const searchableFields: string[] = docCtor?.searchableFields || [];
|
||||||
if (searchableFields.length > 0 && !this.textIndexCreated) {
|
if (searchableFields.length > 0 && !this.textIndexCreated) {
|
||||||
// Build a compound text index spec
|
// Build a compound text index spec
|
||||||
const indexSpec: Record<string, 'text'> = {};
|
const indexSpec: Record<string, 'text'> = {};
|
||||||
|
@ -8,8 +8,7 @@ import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
|
|||||||
|
|
||||||
export type TDocCreation = 'db' | 'new' | 'mixed';
|
export type TDocCreation = 'db' | 'new' | 'mixed';
|
||||||
|
|
||||||
// Set of searchable fields for each class
|
|
||||||
const searchableFieldsMap = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
export function globalSvDb() {
|
export function globalSvDb() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
@ -39,28 +38,15 @@ export function svDb() {
|
|||||||
*/
|
*/
|
||||||
export function searchable() {
|
export function searchable() {
|
||||||
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
|
||||||
console.log(`called searchable() on >${target.constructor.name}.${key}<`);
|
// Attach to class constructor for direct access
|
||||||
|
const ctor = target.constructor as any;
|
||||||
// Initialize the set for this class if it doesn't exist
|
if (!Array.isArray(ctor.searchableFields)) {
|
||||||
const className = target.constructor.name;
|
ctor.searchableFields = [];
|
||||||
if (!searchableFieldsMap.has(className)) {
|
|
||||||
searchableFieldsMap.set(className, new Set<string>());
|
|
||||||
}
|
}
|
||||||
|
ctor.searchableFields.push(key);
|
||||||
// Add the property to the searchable fields set
|
|
||||||
searchableFieldsMap.get(className).add(key);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get searchable fields for a class
|
|
||||||
*/
|
|
||||||
export function getSearchableFields(className: string): string[] {
|
|
||||||
if (!searchableFieldsMap.has(className)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Array.from(searchableFieldsMap.get(className));
|
|
||||||
}
|
|
||||||
// Escape user input for safe use in MongoDB regular expressions
|
// Escape user input for safe use in MongoDB regular expressions
|
||||||
function escapeForRegex(input: string): string {
|
function escapeForRegex(input: string): string {
|
||||||
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@ -318,16 +304,20 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
luceneQuery: string,
|
luceneQuery: string,
|
||||||
): any {
|
): any {
|
||||||
const className = (this as any).className || this.name;
|
const searchableFields = (this as any).getSearchableFields();
|
||||||
const searchableFields = getSearchableFields(className);
|
|
||||||
|
|
||||||
if (searchableFields.length === 0) {
|
if (searchableFields.length === 0) {
|
||||||
throw new Error(`No searchable fields defined for class ${className}`);
|
throw new Error(`No searchable fields defined for class ${this.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
const adapter = new SmartdataLuceneAdapter(searchableFields);
|
||||||
return adapter.convert(luceneQuery);
|
return adapter.convert(luceneQuery);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* List all searchable fields defined on this class
|
||||||
|
*/
|
||||||
|
public static getSearchableFields(): string[] {
|
||||||
|
const ctor = this as any;
|
||||||
|
return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search documents by text or field:value syntax, with safe regex fallback
|
* Search documents by text or field:value syntax, with safe regex fallback
|
||||||
@ -338,10 +328,9 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
this: plugins.tsclass.typeFest.Class<T>,
|
this: plugins.tsclass.typeFest.Class<T>,
|
||||||
query: string,
|
query: string,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const className = (this as any).className || this.name;
|
const searchableFields = (this as any).getSearchableFields();
|
||||||
const searchableFields = getSearchableFields(className);
|
|
||||||
if (searchableFields.length === 0) {
|
if (searchableFields.length === 0) {
|
||||||
throw new Error(`No searchable fields defined for class ${className}`);
|
throw new Error(`No searchable fields defined for class ${this.name}`);
|
||||||
}
|
}
|
||||||
// empty query -> return all
|
// empty query -> return all
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
@ -349,12 +338,13 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
return await (this as any).getInstances({});
|
return await (this as any).getInstances({});
|
||||||
}
|
}
|
||||||
// simple exact field:value (no spaces, no wildcards, no quotes)
|
// simple exact field:value (no spaces, no wildcards, no quotes)
|
||||||
|
// simple exact field:value (no spaces, wildcards, quotes)
|
||||||
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
|
const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
|
||||||
if (simpleExact) {
|
if (simpleExact) {
|
||||||
const field = simpleExact[1];
|
const field = simpleExact[1];
|
||||||
const value = simpleExact[2];
|
const value = simpleExact[2];
|
||||||
if (!searchableFields.includes(field)) {
|
if (!searchableFields.includes(field)) {
|
||||||
throw new Error(`Field '${field}' is not searchable for class ${className}`);
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
}
|
}
|
||||||
return await (this as any).getInstances({ [field]: value });
|
return await (this as any).getInstances({ [field]: value });
|
||||||
}
|
}
|
||||||
@ -374,7 +364,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
const field = wildcardField[1];
|
const field = wildcardField[1];
|
||||||
const pattern = wildcardField[2];
|
const pattern = wildcardField[2];
|
||||||
if (!searchableFields.includes(field)) {
|
if (!searchableFields.includes(field)) {
|
||||||
throw new Error(`Field '${field}' is not searchable for class ${className}`);
|
throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
|
||||||
}
|
}
|
||||||
// escape regex special chars except * and ?, then convert wildcards
|
// escape regex special chars except * and ?, then convert wildcards
|
||||||
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
|
||||||
@ -412,54 +402,7 @@ export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
// INSTANCE
|
||||||
* Search by text across all searchable fields (fallback method)
|
|
||||||
* @param searchText The text to search for in all searchable fields
|
|
||||||
* @returns Array of matching documents
|
|
||||||
*/
|
|
||||||
private static async searchByTextAcrossFields<T>(
|
|
||||||
this: plugins.tsclass.typeFest.Class<T>,
|
|
||||||
searchText: string,
|
|
||||||
): Promise<T[]> {
|
|
||||||
try {
|
|
||||||
const className = (this as any).className || this.name;
|
|
||||||
const searchableFields = getSearchableFields(className);
|
|
||||||
|
|
||||||
// Fallback to direct filter if we have searchable fields
|
|
||||||
if (searchableFields.length > 0) {
|
|
||||||
// Create a simple $or query with regex for each field
|
|
||||||
const orConditions = searchableFields.map((field) => ({
|
|
||||||
[field]: { $regex: searchText, $options: 'i' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const filter = { $or: orConditions };
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try with MongoDB filter first
|
|
||||||
return await (this as any).getInstances(filter);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('MongoDB filter failed, falling back to in-memory search');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: get all and filter in memory
|
|
||||||
const allDocs = await (this as any).getInstances({});
|
|
||||||
const lowerSearchText = searchText.toLowerCase();
|
|
||||||
|
|
||||||
return allDocs.filter((doc: any) => {
|
|
||||||
for (const field of searchableFields) {
|
|
||||||
const value = doc[field];
|
|
||||||
if (value && typeof value === 'string' && value.toLowerCase().includes(lowerSearchText)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error in searchByTextAcrossFields: ${error.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// INSTANCE
|
// INSTANCE
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user