@happyvertical/smrt-core 0.42.6 → 0.42.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -41
- package/agents/collection-reads.md +40 -0
- package/agents/data-query.md +21 -0
- package/agents/latest-related.md +35 -0
- package/agents/memory.md +16 -0
- package/dist/browser.js +2 -2
- package/dist/class.d.ts +7 -0
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +9 -0
- package/dist/class.js.map +1 -1
- package/dist/collection.d.ts +164 -3
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +345 -10
- package/dist/collection.js.map +1 -1
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +10 -2
- package/dist/generators/rest.js.map +1 -1
- package/dist/generators/tool-schema.d.ts +14 -0
- package/dist/generators/tool-schema.d.ts.map +1 -1
- package/dist/generators/tool-schema.js +13 -1
- package/dist/generators/tool-schema.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/interceptors.d.ts +2 -1
- package/dist/interceptors.d.ts.map +1 -1
- package/dist/interceptors.js.map +1 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +37 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +43 -1
- package/dist/prebuild/index.d.ts.map +1 -1
- package/dist/prebuild/index.js +9 -0
- package/dist/prebuild/index.js.map +1 -1
- package/dist/runtime/types.d.ts +2 -2
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +50 -5
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +12 -3
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +24 -3
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts +3 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +24 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +4 -4
package/dist/collection.js
CHANGED
|
@@ -4,10 +4,11 @@ import { buildQueryCacheKey, ensureCacheInvalidationListener, getCacheGeneration
|
|
|
4
4
|
import { toSafeInteger } from "./utils/safe-integer.js";
|
|
5
5
|
import { isTenantScopedClassResolved, resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
|
|
6
6
|
import { GlobalInterceptors, createInterceptorContext, resolveGetStringFilter } from "./interceptors.js";
|
|
7
|
+
import { detectEngine } from "./schema/ddl/index.js";
|
|
7
8
|
import { SmrtClass } from "./class.js";
|
|
8
9
|
import { EmbeddingProvider } from "./embeddings/provider.js";
|
|
9
10
|
import { EmbeddingStorage } from "./embeddings/storage.js";
|
|
10
|
-
import { QueryBoundsError, QueryOrderByError } from "./query-bounds.js";
|
|
11
|
+
import { MAX_LIST_LIMIT, QueryBoundsError, QueryOrderByError } from "./query-bounds.js";
|
|
11
12
|
import { verifyPersistenceTable } from "./schema/table-verifier.js";
|
|
12
13
|
import { fieldsFromClass, formatDataJs, toCamelCase } from "./utils.js";
|
|
13
14
|
import { ObjectRegistry } from "./registry.js";
|
|
@@ -22,6 +23,12 @@ var logger = createLogger({ level: "info" });
|
|
|
22
23
|
* see {@link SmrtCollection.resolveCollectionMemoryOwnerId} (#2365).
|
|
23
24
|
*/
|
|
24
25
|
var COLLECTION_MEMORY_OWNER_ID = "__collection__";
|
|
26
|
+
/** Maximum number of independent facet fields accepted by one call. */
|
|
27
|
+
var MAX_FACET_FIELDS = 20;
|
|
28
|
+
/** Default number of distinct values returned for each facet field. */
|
|
29
|
+
var DEFAULT_FACET_LIMIT = 50;
|
|
30
|
+
/** Hard ceiling for distinct values returned for one facet field. */
|
|
31
|
+
var MAX_FACET_LIMIT = MAX_LIST_LIMIT;
|
|
25
32
|
/**
|
|
26
33
|
* Validate an optional collection-level list bound (#2367).
|
|
27
34
|
*
|
|
@@ -66,6 +73,7 @@ function assertQueryBound(value, parameterName) {
|
|
|
66
73
|
* @returns Modified WHERE clause with qualified _meta_type, or original if no resolution needed
|
|
67
74
|
*/
|
|
68
75
|
function resolveMetaTypeInWhere(where) {
|
|
76
|
+
if (Array.isArray(where)) return where.map((group) => group.map((condition) => resolveMetaTypeInWhere(condition)));
|
|
69
77
|
if (!where?._meta_type || typeof where._meta_type !== "string") return where;
|
|
70
78
|
const metaTypeValue = where._meta_type;
|
|
71
79
|
if (metaTypeValue.includes(":")) return where;
|
|
@@ -76,6 +84,14 @@ function resolveMetaTypeInWhere(where) {
|
|
|
76
84
|
};
|
|
77
85
|
return where;
|
|
78
86
|
}
|
|
87
|
+
/** Add an AND predicate without collapsing a caller's DNF OR branches. */
|
|
88
|
+
function andWhereCondition(where, condition) {
|
|
89
|
+
if (Array.isArray(where)) return where.map((group) => [condition, ...group]);
|
|
90
|
+
return {
|
|
91
|
+
...where ?? {},
|
|
92
|
+
...condition
|
|
93
|
+
};
|
|
94
|
+
}
|
|
79
95
|
/**
|
|
80
96
|
* Field names that carry a stored embedding vector for a class (Issue #2281)
|
|
81
97
|
*
|
|
@@ -298,6 +314,13 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
|
|
|
298
314
|
* ```
|
|
299
315
|
*/
|
|
300
316
|
convertWhereKeys(where) {
|
|
317
|
+
if (Array.isArray(where)) {
|
|
318
|
+
if (where.length === 0 || where.some((group) => !Array.isArray(group) || group.length === 0)) throw new Error("Invalid DNF where clause: each OR branch must contain at least one condition.");
|
|
319
|
+
return where.map((group) => group.map((condition) => this.convertWhereKeysRecord(condition)));
|
|
320
|
+
}
|
|
321
|
+
return this.convertWhereKeysRecord(where);
|
|
322
|
+
}
|
|
323
|
+
convertWhereKeysRecord(where) {
|
|
301
324
|
const VALID_OPERATORS = [
|
|
302
325
|
"=",
|
|
303
326
|
">",
|
|
@@ -389,6 +412,20 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
|
|
|
389
412
|
hasCustomPrimaryKey(fields) {
|
|
390
413
|
return Object.values(fields).some((fieldDef) => fieldDef.primaryKey === true || fieldDef._meta?.primaryKey === true);
|
|
391
414
|
}
|
|
415
|
+
resolvePrimaryKeyField(fields, label) {
|
|
416
|
+
const declared = Object.entries(fields).filter(([, fieldDef]) => fieldDef.primaryKey === true || fieldDef._meta?.primaryKey === true).map(([field]) => field);
|
|
417
|
+
if (declared.length > 1) throw new Error(`${label} declares multiple primary-key fields (${declared.join(", ")}); listWithLatestRelated requires a single primary key.`);
|
|
418
|
+
const field = declared[0] ?? "id";
|
|
419
|
+
return {
|
|
420
|
+
field,
|
|
421
|
+
column: this.toDbColumnName(field)
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
getDatabaseEngine() {
|
|
425
|
+
const db = this.db;
|
|
426
|
+
const isDuckDbConnection = (db.client?.constructor?.name?.toLowerCase() ?? "").includes("duckdb") || typeof db.exportTable === "function" && db.client !== void 0 && "connection" in db.client;
|
|
427
|
+
return detectEngine(db.url || db.config?.url || "", this.getDatabaseEngineHint() || db.type || db.config?.type || (isDuckDbConnection ? "duckdb" : void 0));
|
|
428
|
+
}
|
|
392
429
|
isOmittedCustomPrimaryKeySystemField(fieldName, hasCustomPrimaryKey, explicitFieldNames) {
|
|
393
430
|
return hasCustomPrimaryKey && !explicitFieldNames.has(fieldName) && (fieldName === "id" || fieldName === "slug" || fieldName === "context");
|
|
394
431
|
}
|
|
@@ -436,6 +473,210 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
|
|
|
436
473
|
outputFields
|
|
437
474
|
};
|
|
438
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Parse and validate order terms while retaining the resolved database
|
|
478
|
+
* column. The normal list order builder remains the source of truth for
|
|
479
|
+
* field, sensitive-field, and permission validation; this companion shape
|
|
480
|
+
* lets the latest-row CTE qualify the same safe identifiers with its alias.
|
|
481
|
+
*/
|
|
482
|
+
resolveOrderByTerms(orderBy, fields, label) {
|
|
483
|
+
const validatedSql = this.buildOrderBySql(orderBy, fields);
|
|
484
|
+
const items = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
485
|
+
if (items.length === 0) throw new Error(`Invalid ${label}: at least one order field is required.`);
|
|
486
|
+
const validatedTerms = validatedSql.replace(/^ ORDER BY /, "").split(", ").map((term) => term.split(/\s+/));
|
|
487
|
+
return items.map((item, index) => {
|
|
488
|
+
const [field, direction = "ASC"] = String(item).trim().split(/\s+/);
|
|
489
|
+
if (!field) throw new Error(`Invalid ${label}: an order field is required.`);
|
|
490
|
+
const normalizedDirection = direction.toUpperCase();
|
|
491
|
+
if (normalizedDirection !== "ASC" && normalizedDirection !== "DESC") throw new Error(`Invalid ${label} direction: ${direction}. Must be ASC or DESC.`);
|
|
492
|
+
return {
|
|
493
|
+
column: validatedTerms[index]?.[0] ?? this.toDbColumnName(field),
|
|
494
|
+
direction: normalizedDirection,
|
|
495
|
+
field
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
qualifyLatestOrderTerms(alias, terms) {
|
|
500
|
+
return terms.flatMap((term) => {
|
|
501
|
+
const column = `${alias}.${this.quoteProjectionIdentifier(term.column)}`;
|
|
502
|
+
return [`CASE WHEN ${column} IS NULL THEN 1 ELSE 0 END ASC`, `${column} ${term.direction}`];
|
|
503
|
+
}).join(", ");
|
|
504
|
+
}
|
|
505
|
+
qualifyLatestParentOrderTerms(alias, terms) {
|
|
506
|
+
return terms.flatMap((term) => {
|
|
507
|
+
const column = `${alias}.${this.quoteProjectionIdentifier(term.column)}`;
|
|
508
|
+
return [`CASE WHEN ${column} IS NULL THEN 1 ELSE 0 END ASC`, `${column} ${term.direction}`];
|
|
509
|
+
}).join(", ");
|
|
510
|
+
}
|
|
511
|
+
resolveOneToManyInverseForeignKey(relationship) {
|
|
512
|
+
const inverseCandidates = ObjectRegistry.getInverseRelationshipsForSelf(this._itemClass.name).filter((candidate) => candidate.sourceClass === relationship.targetClass && candidate.type === "foreignKey");
|
|
513
|
+
const explicitForeignKey = relationship.options?.foreignKey;
|
|
514
|
+
const matchedForeignKey = explicitForeignKey ? inverseCandidates.find((candidate) => candidate.fieldName === explicitForeignKey) : void 0;
|
|
515
|
+
if (explicitForeignKey && !matchedForeignKey) throw new Error(`oneToMany ${relationship.fieldName} specifies foreignKey '${explicitForeignKey}', but ${relationship.targetClass} has no matching inverse foreignKey. Candidates: ${inverseCandidates.map((candidate) => candidate.fieldName).join(", ") || "(none)"}`);
|
|
516
|
+
const inverseForeignKey = matchedForeignKey ?? inverseCandidates.find((candidate) => candidate.targetClass === this._itemClass.name) ?? inverseCandidates[0];
|
|
517
|
+
if (!inverseForeignKey) throw new Error(`Could not find inverse foreignKey on ${relationship.targetClass} for oneToMany relationship ${relationship.fieldName}`);
|
|
518
|
+
return inverseForeignKey;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Load a page of hydrated parents with one selected latest row from a
|
|
522
|
+
* declared `@oneToMany` relation. The parent page is sliced only after the
|
|
523
|
+
* latest-row join and optional related sort are applied, so unrelated rows
|
|
524
|
+
* are never hydrated in application code.
|
|
525
|
+
*
|
|
526
|
+
* Missing related rows return `latestRelated: null`. Related order and sort
|
|
527
|
+
* fields place null values after non-null values consistently on all
|
|
528
|
+
* supported adapters. The related row is a plain projection and is never a
|
|
529
|
+
* full `SmrtObject` hydration.
|
|
530
|
+
*
|
|
531
|
+
* @example
|
|
532
|
+
* ```typescript
|
|
533
|
+
* const page = await opportunities.listWithLatestRelated({
|
|
534
|
+
* latestRelated: {
|
|
535
|
+
* relation: 'evaluations',
|
|
536
|
+
* orderBy: 'evaluatedAt DESC',
|
|
537
|
+
* select: ['score', 'evaluatedAt'],
|
|
538
|
+
* sortBy: 'score DESC',
|
|
539
|
+
* },
|
|
540
|
+
* limit: 25,
|
|
541
|
+
* offset: 0,
|
|
542
|
+
* });
|
|
543
|
+
* console.log(page[0].parent, page[0].latestRelated?.score);
|
|
544
|
+
* ```
|
|
545
|
+
*/
|
|
546
|
+
async listWithLatestRelated(options) {
|
|
547
|
+
await this.ensureStorageReady();
|
|
548
|
+
const itemClassName = this.getResolvedItemClassName();
|
|
549
|
+
const itemQualifiedName = this.getResolvedItemQualifiedName();
|
|
550
|
+
const interceptorContext = createInterceptorContext(itemClassName, "list", this.constructor.name);
|
|
551
|
+
const interceptedOptions = await GlobalInterceptors.executeBeforeList(itemClassName, options, interceptorContext) ?? options;
|
|
552
|
+
let { where, offset, limit, orderBy } = interceptedOptions;
|
|
553
|
+
const latestOptions = interceptedOptions.latestRelated ?? options.latestRelated;
|
|
554
|
+
const isSTI = ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti";
|
|
555
|
+
if (isSTI) {
|
|
556
|
+
const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
|
|
557
|
+
if (stiBase && stiBase !== itemQualifiedName) where = {
|
|
558
|
+
_meta_type: itemQualifiedName,
|
|
559
|
+
...where || {}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
where = resolveMetaTypeInWhere(where);
|
|
563
|
+
const relationship = ObjectRegistry.getRelationships(this._itemClass.name).find((candidate) => candidate.fieldName === latestOptions.relation && candidate.type === "oneToMany");
|
|
564
|
+
if (!relationship) throw new Error(`latestRelated.relation '${latestOptions.relation}' is not a declared oneToMany relationship on ${itemClassName}.`);
|
|
565
|
+
const inverseForeignKey = this.resolveOneToManyInverseForeignKey(relationship);
|
|
566
|
+
const relatedCollection = await ObjectRegistry.getCollection(relationship.targetClass, this.options);
|
|
567
|
+
await relatedCollection.ensureStorageReady();
|
|
568
|
+
const relatedFields = relatedCollection.getFieldsSync();
|
|
569
|
+
const relatedItemClassName = relatedCollection.getResolvedItemClassName();
|
|
570
|
+
const relatedQualifiedName = relatedCollection.getResolvedItemQualifiedName();
|
|
571
|
+
const relatedIsSTI = ObjectRegistry.getTableStrategy(relatedQualifiedName) === "sti";
|
|
572
|
+
const relatedInterceptorContext = createInterceptorContext(relatedItemClassName, "list", relatedCollection.constructor.name);
|
|
573
|
+
let relatedWhere = (await GlobalInterceptors.executeBeforeList(relatedItemClassName, { where: {} }, relatedInterceptorContext) ?? { where: {} }).where;
|
|
574
|
+
if (relatedIsSTI) {
|
|
575
|
+
const relatedStiBase = ObjectRegistry.getSTIBase(relatedQualifiedName);
|
|
576
|
+
if (relatedStiBase && relatedStiBase !== relatedQualifiedName) relatedWhere = {
|
|
577
|
+
_meta_type: relatedQualifiedName,
|
|
578
|
+
...relatedWhere || {}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
relatedWhere = resolveMetaTypeInWhere(relatedWhere);
|
|
582
|
+
const { sql: relatedWhereSql, values: relatedWhereValues } = buildWhere(relatedCollection.convertWhereKeys(relatedWhere || {}));
|
|
583
|
+
const relatedPrimaryKey = relatedCollection.resolvePrimaryKeyField(relatedFields, `Related model ${relatedItemClassName}`);
|
|
584
|
+
const relatedSelect = latestOptions.select ?? [relatedPrimaryKey.field];
|
|
585
|
+
const relatedProjection = relatedCollection.resolveProjectionSelect(relatedSelect, relatedFields, relatedIsSTI);
|
|
586
|
+
const relatedOrderTerms = relatedCollection.resolveOrderByTerms(latestOptions.orderBy, relatedFields, "latestRelated.orderBy");
|
|
587
|
+
const relatedSortTerms = latestOptions.sortBy ? relatedCollection.resolveOrderByTerms(latestOptions.sortBy, relatedFields, "latestRelated.sortBy") : [];
|
|
588
|
+
const parentFields = this.getFieldsSync();
|
|
589
|
+
const parentPrimaryKey = this.resolvePrimaryKeyField(parentFields, `Parent model ${itemClassName}`);
|
|
590
|
+
const relatedOutputFields = new Set(relatedProjection.outputFields);
|
|
591
|
+
relatedOutputFields.add(relatedPrimaryKey.field);
|
|
592
|
+
relatedOutputFields.add(inverseForeignKey.fieldName);
|
|
593
|
+
if (parentFields.tenantId && relatedFields.tenantId) relatedOutputFields.add("tenantId");
|
|
594
|
+
for (const term of [...relatedOrderTerms, ...relatedSortTerms]) relatedOutputFields.add(term.field);
|
|
595
|
+
const relatedFieldColumns = new Map([...relatedOrderTerms, ...relatedSortTerms].map((term) => [term.field, term.column]));
|
|
596
|
+
const parentSchema = ObjectRegistry.getSchema(itemQualifiedName) ?? ObjectRegistry.getSchema(itemClassName);
|
|
597
|
+
const liveParentSchema = typeof this.db.getTableSchema === "function" ? await this.db.getTableSchema(this.tableName) : void 0;
|
|
598
|
+
const parentColumnNames = Object.keys(liveParentSchema?.columns ?? parentSchema?.columns ?? {});
|
|
599
|
+
const occupiedParentIdentifiers = /* @__PURE__ */ new Set([
|
|
600
|
+
...Object.keys(parentFields),
|
|
601
|
+
...Object.keys(parentFields).map((fieldName) => this.toDbColumnName(fieldName)),
|
|
602
|
+
...parentColumnNames,
|
|
603
|
+
...Object.keys(parentSchema?.columns ?? {})
|
|
604
|
+
]);
|
|
605
|
+
const relatedFieldAliases = /* @__PURE__ */ new Map();
|
|
606
|
+
let nextAliasIndex = 0;
|
|
607
|
+
for (const fieldName of relatedOutputFields) {
|
|
608
|
+
let alias;
|
|
609
|
+
do
|
|
610
|
+
alias = `__smrt_lr_${nextAliasIndex++}`;
|
|
611
|
+
while (occupiedParentIdentifiers.has(alias) || [...relatedFieldAliases.values()].includes(alias));
|
|
612
|
+
relatedFieldAliases.set(fieldName, alias);
|
|
613
|
+
}
|
|
614
|
+
const parentSelectSql = parentColumnNames.length > 0 ? parentColumnNames.map((columnName) => this.quoteProjectionIdentifier(columnName)).join(", ") : "*";
|
|
615
|
+
const relatedAlias = (fieldName) => {
|
|
616
|
+
const alias = relatedFieldAliases.get(fieldName);
|
|
617
|
+
if (!alias) throw new Error(`Missing latest-related alias for '${fieldName}'.`);
|
|
618
|
+
return alias;
|
|
619
|
+
};
|
|
620
|
+
const relatedSelectExpressions = Array.from(relatedOutputFields).map((fieldName) => {
|
|
621
|
+
const column = relatedFieldColumns.get(fieldName) ?? relatedCollection.toDbColumnName(fieldName);
|
|
622
|
+
const alias = relatedAlias(fieldName);
|
|
623
|
+
return `${relatedCollection.quoteProjectionIdentifier(column)} AS ${relatedCollection.quoteProjectionIdentifier(alias)}`;
|
|
624
|
+
});
|
|
625
|
+
const relatedRankOrder = [relatedCollection.qualifyLatestOrderTerms("r", relatedOrderTerms), `r.${relatedCollection.quoteProjectionIdentifier(relatedPrimaryKey.column)} DESC`].filter(Boolean).join(", ");
|
|
626
|
+
const latestRankAlias = relatedCollection.quoteProjectionIdentifier("__smrt_latest_rank");
|
|
627
|
+
const latestRelatedAlias = (fieldName) => relatedCollection.quoteProjectionIdentifier(relatedAlias(fieldName));
|
|
628
|
+
const relatedCte = `WITH ranked_latest_related AS (SELECT ${relatedSelectExpressions.map((expression) => `r.${expression}`).join(", ")}, ROW_NUMBER() OVER (PARTITION BY r.${relatedCollection.quoteProjectionIdentifier(relatedCollection.toDbColumnName(inverseForeignKey.fieldName))} ORDER BY ${relatedRankOrder}) AS ${latestRankAlias} FROM ${relatedCollection.tableName} r${relatedWhereSql ? ` ${relatedWhereSql}` : ""}) `;
|
|
629
|
+
const { sql: rawWhereSql, values: parentWhereValues } = buildWhere(this.convertWhereKeys(where || {}));
|
|
630
|
+
const parentWhereSql = rawWhereSql.replace(/\$(\d+)/g, (_match, index) => `$${Number(index) + relatedWhereValues.length}`);
|
|
631
|
+
const relatedJoinConditions = [`rr.${latestRankAlias} = 1`, `rr.${latestRelatedAlias(inverseForeignKey.fieldName)} = ${this.quoteProjectionIdentifier(parentPrimaryKey.column)}`];
|
|
632
|
+
if (parentFields.tenantId && relatedFields.tenantId) relatedJoinConditions.push(`(rr.${latestRelatedAlias("tenantId")} = ${this.quoteProjectionIdentifier("tenant_id")} OR (rr.${latestRelatedAlias("tenantId")} IS NULL AND ${this.quoteProjectionIdentifier("tenant_id")} IS NULL))`);
|
|
633
|
+
const orderFragments = [];
|
|
634
|
+
if (relatedSortTerms.length > 0) orderFragments.push(relatedCollection.qualifyLatestParentOrderTerms("rr", relatedSortTerms.map((term) => ({
|
|
635
|
+
...term,
|
|
636
|
+
column: relatedAlias(term.field)
|
|
637
|
+
}))));
|
|
638
|
+
if (orderBy) {
|
|
639
|
+
const parentOrderTerms = this.resolveOrderByTerms(orderBy, parentFields, "orderBy");
|
|
640
|
+
orderFragments.push(parentOrderTerms.map((term) => `${this.quoteProjectionIdentifier(term.column)} ${term.direction}`).join(", "));
|
|
641
|
+
}
|
|
642
|
+
orderFragments.push(`${this.quoteProjectionIdentifier(parentPrimaryKey.column)} ASC`);
|
|
643
|
+
const boundedLimit = this.applyListBounds(assertQueryBound(limit, "limit"));
|
|
644
|
+
const boundedOffset = assertQueryBound(offset, "offset");
|
|
645
|
+
let limitOffsetSql = "";
|
|
646
|
+
const limitOffsetValues = [];
|
|
647
|
+
let nextParameter = relatedWhereValues.length + parentWhereValues.length + 1;
|
|
648
|
+
if (boundedLimit !== void 0) {
|
|
649
|
+
limitOffsetSql += ` LIMIT $${nextParameter++}`;
|
|
650
|
+
limitOffsetValues.push(boundedLimit);
|
|
651
|
+
}
|
|
652
|
+
if (boundedOffset !== void 0) {
|
|
653
|
+
if (boundedLimit === void 0) limitOffsetSql += this.getDatabaseEngine() === "sqlite" ? " LIMIT -1" : " LIMIT ALL";
|
|
654
|
+
limitOffsetSql += ` OFFSET $${nextParameter++}`;
|
|
655
|
+
limitOffsetValues.push(boundedOffset);
|
|
656
|
+
}
|
|
657
|
+
const sql = `${relatedCte}SELECT ${parentSelectSql}, ${Array.from(relatedOutputFields).map((fieldName) => `rr.${latestRelatedAlias(fieldName)} AS ${this.quoteProjectionIdentifier(relatedAlias(fieldName))}`).join(", ")} FROM ${this.quoteProjectionIdentifier(this.tableName)} LEFT JOIN ranked_latest_related rr ON ${relatedJoinConditions.join(" AND ")} ${parentWhereSql} ${orderFragments.length > 0 ? `ORDER BY ${orderFragments.join(", ")}` : ""}${limitOffsetSql}`;
|
|
658
|
+
const rows = await this.db.query(sql, ...relatedWhereValues, ...parentWhereValues, ...limitOffsetValues);
|
|
659
|
+
const instances = [];
|
|
660
|
+
const relatedAliasNames = new Set(relatedFieldAliases.values());
|
|
661
|
+
for (const row of rows.rows) {
|
|
662
|
+
const parentRow = Object.fromEntries(Object.entries(row).filter(([key]) => !relatedAliasNames.has(key)));
|
|
663
|
+
instances.push(await this.hydrateResultRow(parentRow, parentFields, isSTI));
|
|
664
|
+
}
|
|
665
|
+
const relatedByParentId = /* @__PURE__ */ new Map();
|
|
666
|
+
for (const row of rows.rows) {
|
|
667
|
+
const parentId = row[parentPrimaryKey.column];
|
|
668
|
+
if (parentId === null || parentId === void 0) continue;
|
|
669
|
+
const relatedRow = Object.fromEntries(relatedProjection.outputFields.map((fieldName) => [fieldName, row[relatedAlias(fieldName)]]));
|
|
670
|
+
const relatedId = row[relatedAlias(relatedPrimaryKey.field)];
|
|
671
|
+
relatedByParentId.set(String(parentId), relatedId === null || relatedId === void 0 ? null : relatedCollection.formatProjectionRow(relatedRow, relatedFields, relatedProjection.outputFields));
|
|
672
|
+
}
|
|
673
|
+
return (await GlobalInterceptors.executeAfterList(itemClassName, instances, interceptorContext)).map((parent) => {
|
|
674
|
+
return {
|
|
675
|
+
latestRelated: parent[parentPrimaryKey.field] === null || parent[parentPrimaryKey.field] === void 0 ? null : relatedByParentId.get(String(parent[parentPrimaryKey.field])) ?? null,
|
|
676
|
+
parent
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
439
680
|
formatProjectionRow(row, fields, outputFields) {
|
|
440
681
|
const formattedData = this.withHydratedCoreFields(formatDataJs(row, fields));
|
|
441
682
|
const projected = {};
|
|
@@ -788,10 +1029,7 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
|
|
|
788
1029
|
const isSTI = ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti";
|
|
789
1030
|
if (isSTI) {
|
|
790
1031
|
const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
|
|
791
|
-
if (stiBase && stiBase !== itemQualifiedName) where = {
|
|
792
|
-
_meta_type: itemQualifiedName,
|
|
793
|
-
...where || {}
|
|
794
|
-
};
|
|
1032
|
+
if (stiBase && stiBase !== itemQualifiedName) where = andWhereCondition(where, { _meta_type: itemQualifiedName });
|
|
795
1033
|
}
|
|
796
1034
|
where = resolveMetaTypeInWhere(where);
|
|
797
1035
|
const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where || {}));
|
|
@@ -1360,16 +1598,113 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
|
|
|
1360
1598
|
let { where } = await GlobalInterceptors.executeBeforeList(itemClassName, options, interceptorContext) ?? options ?? {};
|
|
1361
1599
|
if (ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti") {
|
|
1362
1600
|
const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
|
|
1363
|
-
if (stiBase && stiBase !== itemQualifiedName) where = {
|
|
1364
|
-
_meta_type: itemQualifiedName,
|
|
1365
|
-
...where || {}
|
|
1366
|
-
};
|
|
1601
|
+
if (stiBase && stiBase !== itemQualifiedName) where = andWhereCondition(where, { _meta_type: itemQualifiedName });
|
|
1367
1602
|
}
|
|
1368
1603
|
where = resolveMetaTypeInWhere(where);
|
|
1369
1604
|
const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where || {}));
|
|
1370
1605
|
return toSafeInteger((await this.db.query(`SELECT COUNT(*) as count FROM ${this.tableName} ${whereSql}`, ...whereValues)).rows[0].count, "Collection count");
|
|
1371
1606
|
}
|
|
1372
1607
|
/**
|
|
1608
|
+
* Return database-backed distinct values and row counts for one or more
|
|
1609
|
+
* column-backed fields.
|
|
1610
|
+
*
|
|
1611
|
+
* Each requested field is executed as a bounded `GROUP BY` query. Keeping
|
|
1612
|
+
* one query per field uses standard SQL while avoiding a full collection
|
|
1613
|
+
* read or model hydration. Scalar facet behavior is covered on SQLite and
|
|
1614
|
+
* DuckDB; optional PostgreSQL scalar coverage runs in the `test:postgres`
|
|
1615
|
+
* lane when `SMRT_TEST_POSTGRES_URL` is configured.
|
|
1616
|
+
* The same `beforeList` interceptors as `list()` and `count()` run first,
|
|
1617
|
+
* so tenancy and other read scopes are applied to every facet query.
|
|
1618
|
+
*
|
|
1619
|
+
* Facets group by the value stored in the column. SMRT does not split,
|
|
1620
|
+
* unnest, or otherwise interpret array/string-list fields; consumers that
|
|
1621
|
+
* need a facet per array member should maintain a scalar join table or use
|
|
1622
|
+
* a consumer-specific query. JSON fields are grouped by their stored JSON
|
|
1623
|
+
* value and decoded in the returned `value` when the field metadata allows
|
|
1624
|
+
* it. Array/JSON encoding behavior is adapter-specific and is covered by
|
|
1625
|
+
* the SQLite/DuckDB tests only; the optional PostgreSQL test covers scalar
|
|
1626
|
+
* grouping.
|
|
1627
|
+
*
|
|
1628
|
+
* @param options.fields One or more fields, optionally with per-field limits
|
|
1629
|
+
* @param options.where Filter conditions using the same syntax as `list()`
|
|
1630
|
+
* @returns One value/count list per requested field, in request order
|
|
1631
|
+
*
|
|
1632
|
+
* Limits are always bounded: an explicit per-field limit is clamped to
|
|
1633
|
+
* {@link MAX_FACET_LIMIT}, the collection's `maxListLimit` when configured,
|
|
1634
|
+
* and the lower of those ceilings. When omitted, the effective limit is the
|
|
1635
|
+
* collection's `defaultListLimit` or {@link DEFAULT_FACET_LIMIT}, subject to
|
|
1636
|
+
* the same ceilings. This keeps facet queries bounded even when collection
|
|
1637
|
+
* list bounds are unset.
|
|
1638
|
+
*/
|
|
1639
|
+
async facets(options) {
|
|
1640
|
+
await this.ensureStorageReady();
|
|
1641
|
+
if (!options || !Array.isArray(options.fields)) throw new Error("Invalid facets option: fields must be an array.");
|
|
1642
|
+
if (options.fields.length === 0) throw new Error("Invalid facets option: at least one field is required.");
|
|
1643
|
+
if (options.fields.length > 20) throw new Error(`Invalid facets option: at most 20 fields are allowed.`);
|
|
1644
|
+
const itemClassName = this.getResolvedItemClassName();
|
|
1645
|
+
const itemQualifiedName = this.getResolvedItemQualifiedName();
|
|
1646
|
+
const interceptorContext = createInterceptorContext(itemClassName, "list", this.constructor.name);
|
|
1647
|
+
const interceptedOptions = await GlobalInterceptors.executeBeforeList(itemClassName, options, interceptorContext) ?? options ?? {};
|
|
1648
|
+
let { where } = interceptedOptions;
|
|
1649
|
+
const isSTI = ObjectRegistry.getTableStrategy(itemQualifiedName) === "sti";
|
|
1650
|
+
if (isSTI) {
|
|
1651
|
+
const stiBase = ObjectRegistry.getSTIBase(itemQualifiedName);
|
|
1652
|
+
if (stiBase && stiBase !== itemQualifiedName) where = andWhereCondition(where, { _meta_type: itemQualifiedName });
|
|
1653
|
+
}
|
|
1654
|
+
where = resolveMetaTypeInWhere(where);
|
|
1655
|
+
const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where || {}));
|
|
1656
|
+
const fields = this.getFieldsSync();
|
|
1657
|
+
const effectiveFields = interceptedOptions.fields ?? options.fields;
|
|
1658
|
+
if (!Array.isArray(effectiveFields) || effectiveFields.length === 0) throw new Error("Invalid facets option: at least one field is required after interception.");
|
|
1659
|
+
if (effectiveFields.length > 20) throw new Error(`Invalid facets option: at most 20 fields are allowed after interception.`);
|
|
1660
|
+
const requested = effectiveFields.map((entry) => typeof entry === "string" ? { field: entry } : entry);
|
|
1661
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1662
|
+
const results = [];
|
|
1663
|
+
for (const request of requested) {
|
|
1664
|
+
if (!request || typeof request.field !== "string") throw new Error("Invalid facet field: expected a string or { field, limit }.");
|
|
1665
|
+
const field = request.field;
|
|
1666
|
+
if (seen.has(field)) throw new Error(`Invalid facet field: '${field}' was requested more than once.`);
|
|
1667
|
+
seen.add(field);
|
|
1668
|
+
this.resolveProjectionSelect([field], fields, isSTI);
|
|
1669
|
+
const columnName = this.toDbColumnName(field);
|
|
1670
|
+
const quotedColumn = this.quoteProjectionIdentifier(columnName);
|
|
1671
|
+
const quotedField = this.quoteProjectionIdentifier(field);
|
|
1672
|
+
const requestedLimit = request.limit === void 0 ? this._defaultListLimit ?? DEFAULT_FACET_LIMIT : assertQueryBound(request.limit, `facet limit for '${field}'`);
|
|
1673
|
+
const collectionLimit = this._maxListLimit ?? MAX_FACET_LIMIT;
|
|
1674
|
+
const limit = Math.min(requestedLimit ?? DEFAULT_FACET_LIMIT, collectionLimit, MAX_FACET_LIMIT);
|
|
1675
|
+
const sql = `SELECT ${quotedColumn} AS ${quotedField}, COUNT(*) AS "__smrt_facet_count" FROM ${this.tableName} ${whereSql} GROUP BY ${quotedColumn} ORDER BY "__smrt_facet_count" DESC, CASE WHEN ${quotedColumn} IS NULL THEN 1 ELSE 0 END ASC, ${quotedColumn} ASC LIMIT $${whereValues.length + 1}`;
|
|
1676
|
+
const params = [...whereValues, limit];
|
|
1677
|
+
const queryResult = await this.db.query(sql, ...params);
|
|
1678
|
+
results.push({
|
|
1679
|
+
field,
|
|
1680
|
+
values: queryResult.rows.map((row) => {
|
|
1681
|
+
const rawValue = row[field];
|
|
1682
|
+
const formatted = formatDataJs({ [columnName]: rawValue }, fields);
|
|
1683
|
+
const formattedKey = Object.hasOwn(formatted, field) ? field : toCamelCase(field);
|
|
1684
|
+
return {
|
|
1685
|
+
value: Object.hasOwn(formatted, formattedKey) ? formatted[formattedKey] : rawValue,
|
|
1686
|
+
count: toSafeInteger(row.__smrt_facet_count, `Facet count for '${field}'`)
|
|
1687
|
+
};
|
|
1688
|
+
})
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
return results;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Return both the tenant/read-scoped total and a filtered count.
|
|
1695
|
+
*
|
|
1696
|
+
* This intentionally issues two `COUNT(*)` queries rather than reading
|
|
1697
|
+
* rows into application memory. `count()` applies `beforeList` for each
|
|
1698
|
+
* query, so the unfiltered total remains tenant-scoped while the filtered
|
|
1699
|
+
* count adds the caller's `where` conditions.
|
|
1700
|
+
*/
|
|
1701
|
+
async counts(options = {}) {
|
|
1702
|
+
return {
|
|
1703
|
+
total: await this.count(),
|
|
1704
|
+
filtered: await this.count(options)
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1373
1708
|
* Resolve this collection's STI child discriminator — the qualified item
|
|
1374
1709
|
* class name to use as a `_meta_type` scope — when the collection's item is
|
|
1375
1710
|
* an STI **child** (shares a table with sibling subtypes), or `null` for STI
|
|
@@ -1971,6 +2306,6 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
|
|
|
1971
2306
|
}
|
|
1972
2307
|
};
|
|
1973
2308
|
//#endregion
|
|
1974
|
-
export { SmrtCollection };
|
|
2309
|
+
export { DEFAULT_FACET_LIMIT, MAX_FACET_FIELDS, MAX_FACET_LIMIT, SmrtCollection };
|
|
1975
2310
|
|
|
1976
2311
|
//# sourceMappingURL=collection.js.map
|