Skip to content

Commit bd5490d

Browse files
hotlongCopilot
andcommitted
feat(rest,objectql): M10.5 global cross-object search
Add /api/v1/search?q=&objects=&limit=&perObject= endpoint that scans all registered objects (opted into enable.searchable !== false) over their searchable text-like fields using AND-of-OR $contains predicates, returning hits with object, id, rendered title (via titleFormat template), snippet, and full record. - protocol.ts: new ObjectStackProtocolImplementation.searchAll(). Supports record-shape and array-shape field maps, multi-term AND, per-object cap, RBAC via forwarded context, snippet extraction, title rendering with titleFormat template interpolation and fallbacks (displayNameField → name/full_name/ title/subject/company → first_name+last_name → id). - rest-server.ts: new registerSearchEndpoints + enableSearch flag (default on); 401 when unauthenticated; consistent error envelope via mapDataError. Verified via curl: q=alice returns lead 'Alice Martinez - NextGen Retail' with snippet; multi-term 'nextgen retail' works; objects=account,contact,opportunity filter respected; 401 for anon callers; SQL-leak detector still active. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1d11f31 commit bd5490d

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

packages/objectql/src/protocol.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,191 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
835835
};
836836
}
837837

838+
// ==========================================
839+
// Global Search (M10.5)
840+
// ==========================================
841+
/**
842+
* Cross-object substring search across all registered objects that opt in
843+
* via `enable.searchable !== false` and `enable.apiEnabled !== false`.
844+
* Searches text-like fields (text/textarea/email/url/phone/markdown/html/string)
845+
* whose `searchable: true` flag is set, falling back to the object's
846+
* `displayNameField` (or `name`) when no fields are explicitly searchable.
847+
*
848+
* The query is split into whitespace-separated terms; each term must match
849+
* (case-insensitive LIKE) at least one searchable field. RBAC/RLS is
850+
* enforced by forwarding the caller's `context` to `engine.find` so users
851+
* only see records they are entitled to read.
852+
*/
853+
async searchAll(request: {
854+
q: string;
855+
objects?: string[];
856+
limit?: number;
857+
perObject?: number;
858+
context?: any;
859+
}): Promise<{
860+
query: string;
861+
hits: Array<{
862+
object: string;
863+
id: string;
864+
title: string;
865+
snippet?: string;
866+
record: any;
867+
}>;
868+
totalObjects: number;
869+
totalHits: number;
870+
truncated: boolean;
871+
}> {
872+
const q = (request.q ?? '').trim();
873+
if (!q) {
874+
return { query: '', hits: [], totalObjects: 0, totalHits: 0, truncated: false };
875+
}
876+
877+
const overallLimit = Math.max(1, Math.min(100, Number(request.limit ?? 20)));
878+
const perObject = Math.max(1, Math.min(25, Number(request.perObject ?? 5)));
879+
const objectsFilter = request.objects && request.objects.length
880+
? new Set(request.objects)
881+
: null;
882+
883+
// Tokenise: each token must match (LIKE %term%) at least one searchable field
884+
const terms = q.split(/\s+/).filter(Boolean).slice(0, 8);
885+
886+
const allObjects = (this.engine as any).registry?.getAllObjects?.() ?? [];
887+
const hits: Array<{ object: string; id: string; title: string; snippet?: string; record: any }> = [];
888+
let objectsScanned = 0;
889+
890+
for (const obj of allObjects) {
891+
if (hits.length >= overallLimit) break;
892+
if (!obj?.name) continue;
893+
if (objectsFilter && !objectsFilter.has(obj.name)) continue;
894+
895+
// Skip platform/system tables and opt-outs
896+
const enable = obj.enable ?? {};
897+
if (enable.searchable === false) continue;
898+
if (enable.apiEnabled === false) continue;
899+
// Skip noisy system tables by name prefix
900+
if (obj.name.startsWith('sys_audit_log')
901+
|| obj.name.startsWith('sys_activity')
902+
|| obj.name.startsWith('sys_session')
903+
|| obj.name.startsWith('sys_presence')
904+
|| obj.name.startsWith('sys_metadata')
905+
|| obj.name.startsWith('sys_account')) {
906+
continue;
907+
}
908+
909+
const fieldsRaw = obj.fields;
910+
const fields: Array<{ name: string; type: string; searchable?: boolean }> =
911+
Array.isArray(fieldsRaw)
912+
? fieldsRaw
913+
: (fieldsRaw && typeof fieldsRaw === 'object'
914+
? Object.entries(fieldsRaw).map(([name, f]: [string, any]) => ({ name, ...(f || {}) }))
915+
: []);
916+
const TEXT_TYPES = new Set(['text', 'textarea', 'string', 'email', 'url', 'phone', 'markdown', 'html']);
917+
const fieldByName = new Map(fields.map(f => [f.name, f]));
918+
const hasField = (n: string) => fieldByName.has(n);
919+
// Resolve title for a record using titleFormat → displayNameField →
920+
// common conventional fields → id. titleFormat supports simple
921+
// `{field}` placeholders (the `template` dialect); unresolved
922+
// placeholders fall through to the next strategy.
923+
const titleFormatSource = (obj.titleFormat && (obj.titleFormat.source || obj.titleFormat))
924+
|| undefined;
925+
const renderTitle = (row: any): string => {
926+
if (typeof titleFormatSource === 'string') {
927+
let allResolved = true;
928+
const rendered = titleFormatSource.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key) => {
929+
const v = row[key];
930+
if (v == null || v === '') { allResolved = false; return ''; }
931+
return String(v);
932+
}).trim();
933+
if (rendered && allResolved) return rendered;
934+
if (rendered) return rendered.replace(/\s+-\s+$/, '').replace(/^\s+-\s+/, '').trim() || row.id;
935+
}
936+
const candidates = [
937+
obj.displayNameField,
938+
'name', 'full_name', 'title', 'subject', 'label', 'company',
939+
].filter((c): c is string => typeof c === 'string' && hasField(c));
940+
for (const c of candidates) {
941+
const v = row[c];
942+
if (v != null && String(v).trim()) return String(v);
943+
}
944+
const fn = row.first_name, ln = row.last_name;
945+
if (fn || ln) return `${fn ?? ''} ${ln ?? ''}`.trim();
946+
return String(row.id);
947+
};
948+
949+
const titleFieldName = obj.displayNameField
950+
|| (hasField('name') ? 'name' : undefined)
951+
|| (hasField('title') ? 'title' : undefined)
952+
|| fields.find(f => TEXT_TYPES.has(f.type))?.name;
953+
954+
let searchableFields = fields
955+
.filter(f => f && TEXT_TYPES.has(f.type) && f.searchable === true)
956+
.map(f => f.name as string);
957+
958+
// Fallback: if no field is explicitly searchable, scan the title field
959+
if (searchableFields.length === 0 && titleFieldName) {
960+
searchableFields = [titleFieldName];
961+
}
962+
if (searchableFields.length === 0) continue;
963+
964+
objectsScanned++;
965+
966+
// Build AND-of-OR filter: every term must hit at least one field.
967+
// ObjectQL exposes case-insensitive substring matching via `$contains`.
968+
const andClauses = terms.map(term => ({
969+
$or: searchableFields.map(f => ({ [f]: { $contains: term } })),
970+
}));
971+
const where = andClauses.length === 1 ? andClauses[0] : { $and: andClauses };
972+
973+
try {
974+
const opts: any = {
975+
where,
976+
limit: perObject,
977+
orderBy: [{ field: 'updated_at', direction: 'desc' }],
978+
};
979+
if (request.context !== undefined) opts.context = request.context;
980+
981+
const rows = await this.engine.find(obj.name, opts);
982+
for (const row of rows || []) {
983+
if (hits.length >= overallLimit) break;
984+
const title = renderTitle(row);
985+
// Build snippet from first searchable field that contains a term
986+
let snippet: string | undefined;
987+
for (const f of searchableFields) {
988+
const v = row[f];
989+
if (typeof v === 'string' && v) {
990+
const lc = v.toLowerCase();
991+
const idx = terms.map(t => lc.indexOf(t.toLowerCase())).find(i => i >= 0);
992+
if (idx != null && idx >= 0) {
993+
const start = Math.max(0, idx - 30);
994+
const end = Math.min(v.length, idx + 90);
995+
snippet = (start > 0 ? '…' : '') + v.slice(start, end) + (end < v.length ? '…' : '');
996+
break;
997+
}
998+
}
999+
}
1000+
hits.push({
1001+
object: obj.name,
1002+
id: row.id,
1003+
title,
1004+
snippet,
1005+
record: row,
1006+
});
1007+
}
1008+
} catch {
1009+
// RBAC denial or driver hiccup — skip silently per object
1010+
continue;
1011+
}
1012+
}
1013+
1014+
return {
1015+
query: q,
1016+
hits,
1017+
totalObjects: objectsScanned,
1018+
totalHits: hits.length,
1019+
truncated: hits.length >= overallLimit,
1020+
};
1021+
}
1022+
8381023
// ==========================================
8391024
// Metadata Caching
8401025
// ==========================================

packages/rest/src/rest-server.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ type NormalizedRestServerConfig = {
172172
enableUi: boolean;
173173
enableBatch: boolean;
174174
enableDiscovery: boolean;
175+
enableSearch?: boolean;
175176
enableProjectScoping: boolean;
176177
projectResolution: 'required' | 'optional' | 'auto';
177178
requireAuth: boolean;
@@ -708,6 +709,7 @@ export class RestServer {
708709
enableUi: api.enableUi ?? true,
709710
enableBatch: api.enableBatch ?? true,
710711
enableDiscovery: api.enableDiscovery ?? true,
712+
enableSearch: (api as any).enableSearch ?? true,
711713
enableProjectScoping: api.enableProjectScoping ?? false,
712714
projectResolution: api.projectResolution ?? 'auto',
713715
requireAuth: (api as any).requireAuth ?? false,
@@ -799,6 +801,9 @@ export class RestServer {
799801
if (this.config.api.enableCrud) {
800802
this.registerCrudEndpoints(bp);
801803
}
804+
if (this.config.api.enableSearch ?? true) {
805+
this.registerSearchEndpoints(bp);
806+
}
802807
if (this.config.api.enableBatch) {
803808
this.registerBatchEndpoints(bp);
804809
}
@@ -1393,6 +1398,54 @@ export class RestServer {
13931398
}
13941399
}
13951400

1401+
/**
1402+
* Register global cross-object search endpoint (M10.5).
1403+
* GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
1404+
*/
1405+
private registerSearchEndpoints(basePath: string): void {
1406+
const isScoped = basePath.includes('/projects/:projectId');
1407+
this.routeManager.register({
1408+
method: 'GET',
1409+
path: `${basePath}/search`,
1410+
handler: async (req: any, res: any) => {
1411+
try {
1412+
const projectId = isScoped ? req.params?.projectId : undefined;
1413+
const p = await this.resolveProtocol(projectId, req);
1414+
const context = await this.resolveExecCtx(projectId, req);
1415+
if (this.enforceAuth(req, res, context)) return;
1416+
const searchAll = (p as any).searchAll;
1417+
if (typeof searchAll !== 'function') {
1418+
res.status(501).json({ code: 'NOT_IMPLEMENTED', message: 'Search not supported by this protocol' });
1419+
return;
1420+
}
1421+
const q = String(req.query?.q ?? req.query?.query ?? '');
1422+
const objectsParam = req.query?.objects;
1423+
const objects = typeof objectsParam === 'string'
1424+
? objectsParam.split(',').map((s: string) => s.trim()).filter(Boolean)
1425+
: Array.isArray(objectsParam) ? objectsParam : undefined;
1426+
const result = await searchAll.call(p, {
1427+
q,
1428+
objects,
1429+
limit: req.query?.limit ? Number(req.query.limit) : undefined,
1430+
perObject: req.query?.perObject ? Number(req.query.perObject) : undefined,
1431+
...(context ? { context } : {}),
1432+
});
1433+
res.json(result);
1434+
} catch (error: any) {
1435+
const mapped = mapDataError(error);
1436+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== 'VALIDATION_FAILED') {
1437+
logError('[REST] Unhandled error:', error);
1438+
}
1439+
res.status(mapped.status).json(mapped.body);
1440+
}
1441+
},
1442+
metadata: {
1443+
summary: 'Global cross-object search',
1444+
tags: ['search'],
1445+
},
1446+
});
1447+
}
1448+
13961449
/**
13971450
* Register batch operation endpoints
13981451
*/

0 commit comments

Comments
 (0)