@tstdl/base 0.93.10 → 0.93.12

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.
@@ -10,6 +10,13 @@ export type InjectableOptions<T, A, C extends Record = Record> = RegistrationOpt
10
10
  alias?: OneOrMany<InjectionToken>;
11
11
  /** Custom provider. Useful for example if initialization is required */
12
12
  provider?: Provider<T, A, C>;
13
+ /**
14
+ * Which InjectableOptions to inherit from the parent (class which is extended from).
15
+ * If true (default), all options are inherited.
16
+ * If false, no options are inherited.
17
+ * If array of keys, only the provided options are inherited.
18
+ */
19
+ inheritOptions?: boolean | (keyof InjectableOptions<T, A, C>)[];
13
20
  };
14
21
  export type InjectableOptionsWithoutLifecycle<T, A, C extends Record = Record> = Simplify<TypedOmit<InjectableOptions<T, A, C>, 'lifecycle'>>;
15
22
  /**
@@ -1,7 +1,8 @@
1
1
  /* eslint-disable @typescript-eslint/naming-convention */
2
2
  import { createClassDecorator, createDecorator, reflectionRegistry } from '../reflection/index.js';
3
3
  import { toArray } from '../utils/array/array.js';
4
- import { isDefined, isFunction, isNotNull } from '../utils/type-guards.js';
4
+ import { filterObject } from '../utils/object/object.js';
5
+ import { isArray, isDefined, isFunction, isNotNull } from '../utils/type-guards.js';
5
6
  import { Injector } from './injector.js';
6
7
  import { injectMetadataSymbol, injectableMetadataSymbol, injectableOptionsSymbol } from './symbols.js';
7
8
  /**
@@ -33,20 +34,23 @@ export function Injectable(options = {}) {
33
34
  const { alias: aliases, provider, ...registrationOptions } = options;
34
35
  const token = data.constructor;
35
36
  let mergedRegistationOptions = registrationOptions;
36
- if (isNotNull(metadata.parent)) {
37
+ if (isNotNull(metadata.parent) && (options.inheritOptions != false)) {
37
38
  const parentOptions = reflectionRegistry.getMetadata(metadata.parent)?.data.tryGet(injectableOptionsSymbol);
38
39
  if (isDefined(parentOptions)) {
39
40
  const { alias: _, provider: __, ...parentRegistrationOptions } = parentOptions;
41
+ const optionsToInherit = isArray(options.inheritOptions)
42
+ ? filterObject(parentRegistrationOptions, (_value, key) => options.inheritOptions.includes(key))
43
+ : parentRegistrationOptions;
40
44
  mergedRegistationOptions = {
41
- ...parentRegistrationOptions,
45
+ ...optionsToInherit,
42
46
  ...registrationOptions,
43
- providers: [...(parentRegistrationOptions.providers ?? []), ...(registrationOptions.providers ?? [])],
47
+ providers: [...(optionsToInherit.providers ?? []), ...(registrationOptions.providers ?? [])],
44
48
  afterResolve: (instance, argument, context) => {
45
- parentRegistrationOptions.afterResolve?.(instance, argument, context);
49
+ optionsToInherit.afterResolve?.(instance, argument, context);
46
50
  registrationOptions.afterResolve?.(instance, argument, context);
47
51
  },
48
52
  metadata: {
49
- ...parentRegistrationOptions.metadata,
53
+ ...optionsToInherit.metadata,
50
54
  ...registrationOptions.metadata,
51
55
  },
52
56
  };
@@ -12,6 +12,7 @@ import { assert, isArray, isBoolean, isDefined, isFunction, isNotNull, isNotObje
12
12
  import { setCurrentInjectionContext } from './inject.js';
13
13
  import { afterResolve } from './interfaces.js';
14
14
  import { isClassProvider, isFactoryProvider, isProviderWithInitializer, isTokenProvider, isValueProvider } from './provider.js';
15
+ import { RegistrationError } from './registration.error.js';
15
16
  import { runInResolutionContext } from './resolution.js';
16
17
  import { ResolveChain } from './resolve-chain.js';
17
18
  import { ResolveError } from './resolve.error.js';
@@ -277,7 +278,7 @@ export class Injector {
277
278
  return forwardRef;
278
279
  }
279
280
  if (isUndefined(token)) {
280
- throw new ResolveError('Token is undefined - this might be because of circular dependencies, use alias or forwardRef in this case.', chain);
281
+ throw new ResolveError('Token is undefined. This might be due to a circular dependency. Consider using an alias or forwardRef.', chain);
281
282
  }
282
283
  const registration = (options.skipSelf == true) ? undefined : this.tryGetRegistration(token);
283
284
  if (isDefined(registration)) {
@@ -302,7 +303,7 @@ export class Injector {
302
303
  return forwardRef;
303
304
  }
304
305
  if (isUndefined(token)) {
305
- throw new ResolveError('Token is undefined - this might be because of circular dependencies, use alias or forwardRef in this case.', chain);
306
+ throw new ResolveError('Token is undefined. This might be due to a circular dependency. Consider using an alias or forwardRef.', chain);
306
307
  }
307
308
  const registration = (options.skipSelf == true) ? undefined : this.tryGetRegistration(token);
308
309
  if (isDefined(registration)) {
@@ -331,8 +332,11 @@ export class Injector {
331
332
  const resolutionScoped = registration.options.lifecycle == 'resolution';
332
333
  const injectorScoped = registration.options.lifecycle == 'injector';
333
334
  const singletonScoped = registration.options.lifecycle == 'singleton';
334
- const resolveArgument = argument ?? registration.options.defaultArgument ?? (registration.options.defaultArgumentProvider?.(injector.getResolveContext(resolutionTag, context, chain)));
335
- const argumentIdentity = resolveArgumentIdentity(registration, resolveArgument);
335
+ let resolveArgument = argument ?? registration.options.defaultArgument;
336
+ if (isUndefined(resolveArgument) && isFunction(registration.options.defaultArgumentProvider)) {
337
+ resolveArgument = wrapInResolveError(() => registration.options.defaultArgumentProvider(injector.getResolveContext(resolutionTag, context, chain)), 'Error in defaultArgumentProvider', chain);
338
+ }
339
+ const argumentIdentity = resolveArgumentIdentity(registration, resolveArgument, chain);
336
340
  if (resolutionScoped && context.resolutionScopedResolutions.hasFlat(token, argumentIdentity)) {
337
341
  return context.resolutionScopedResolutions.getFlat(token, argumentIdentity).value;
338
342
  }
@@ -342,9 +346,7 @@ export class Injector {
342
346
  else if (singletonScoped && registration.resolutions.has(argumentIdentity)) {
343
347
  return registration.resolutions.get(argumentIdentity);
344
348
  }
345
- const resolutionContext = {
346
- afterResolveRegistrations: [],
347
- };
349
+ const resolutionContext = { afterResolveRegistrations: [] };
348
350
  const value = injector._resolveProvider(resolutionTag, registration, resolveArgument, options, context, resolutionContext, injectionContext, chain);
349
351
  const resolution = {
350
352
  tag: resolutionTag,
@@ -381,23 +383,16 @@ export class Injector {
381
383
  const arg = resolveArgument ?? provider.defaultArgument ?? provider.defaultArgumentProvider?.();
382
384
  injectionContext.argument = arg;
383
385
  if ((provider.useClass.length > 0) && (isUndefined(typeMetadata) || !typeMetadata.data.has(injectableMetadataSymbol))) {
384
- throw new ResolveError(`${provider.useClass.name} has constructor parameters but is not injectable.`, chain);
386
+ throw new ResolveError(`${provider.useClass.name} has constructor parameters but is not decorated with @Injectable.`, chain);
385
387
  }
386
388
  const parameters = (typeMetadata?.parameters ?? []).map((metadata) => this.resolveClassInjection(resolutionTag, context, provider.useClass, metadata, arg, chain));
387
- try {
388
- result = { value: runInResolutionContext(resolutionContext, () => Reflect.construct(provider.useClass, parameters)) };
389
- }
390
- catch (error) {
391
- if (error instanceof ResolveError) {
392
- throw error;
393
- }
394
- throw new ResolveError('Error in class constructor.', chain, error);
395
- }
389
+ const value = wrapInResolveError(() => runInResolutionContext(resolutionContext, () => Reflect.construct(provider.useClass, parameters)), `Error during construction of '${provider.useClass.name}'`, chain);
390
+ result = { value };
396
391
  }
397
- if (isValueProvider(provider)) {
392
+ else if (isValueProvider(provider)) {
398
393
  result = { value: provider.useValue };
399
394
  }
400
- if (isTokenProvider(provider)) {
395
+ else if (isTokenProvider(provider)) {
401
396
  const innerToken = (provider.useToken ?? provider.useTokenProvider());
402
397
  const arg = resolveArgument ?? provider.defaultArgument ?? provider.defaultArgumentProvider?.();
403
398
  injectionContext.argument = arg;
@@ -406,17 +401,13 @@ export class Injector {
406
401
  }
407
402
  result = { value: this._resolve(innerToken, arg, options, context, chain.addToken(innerToken)) };
408
403
  }
409
- if (isFactoryProvider(provider)) {
404
+ else if (isFactoryProvider(provider)) {
410
405
  const arg = resolveArgument ?? provider.defaultArgument ?? provider.defaultArgumentProvider?.();
411
406
  injectionContext.argument = arg;
412
- try {
413
- result = { value: runInResolutionContext(resolutionContext, () => provider.useFactory(arg, this.getResolveContext(resolutionTag, context, chain))) };
414
- }
415
- catch (error) {
416
- throw new ResolveError('Error in provider factory.', chain, error);
417
- }
407
+ const value = wrapInResolveError(() => runInResolutionContext(resolutionContext, () => provider.useFactory(arg, this.getResolveContext(resolutionTag, context, chain))), 'Error in provider factory', chain);
408
+ result = { value };
418
409
  }
419
- if (isUndefined(result)) {
410
+ else {
420
411
  throw new Error('Unsupported provider.');
421
412
  }
422
413
  if (isSyncOrAsyncDisposable(result.value) && !this.#disposableStackRegistrations.has(result.value)) {
@@ -434,9 +425,9 @@ export class Injector {
434
425
  const injectMetadata = metadata.data.tryGet(injectMetadataSymbol) ?? {};
435
426
  const injectToken = (injectMetadata.injectToken ?? metadata.type);
436
427
  if (isDefined(injectMetadata.injectArgumentMapper) && (!this.hasRegistration(injectToken) || isDefined(resolveArgument) || isUndefined(injectToken))) {
437
- return injectMetadata.injectArgumentMapper(resolveArgument);
428
+ return wrapInResolveError(() => injectMetadata.injectArgumentMapper(resolveArgument), 'Error in injectArgumentMapper', getChain(injectToken));
438
429
  }
439
- const parameterResolveArgument = injectMetadata.forwardArgumentMapper?.(resolveArgument) ?? injectMetadata.resolveArgumentProvider?.(this.getResolveContext(resolutionTag, context, getChain(injectToken)));
430
+ const parameterResolveArgument = wrapInResolveError(() => injectMetadata.forwardArgumentMapper?.(resolveArgument) ?? injectMetadata.resolveArgumentProvider?.(this.getResolveContext(resolutionTag, context, getChain(injectToken))), 'Error in parameter argument provider (forwardArgumentMapper or resolveArgumentProvider)', getChain(injectToken));
440
431
  const { forwardRef } = injectMetadata;
441
432
  if (isDefined(forwardRef) && isDefined(injectMetadata.mapper)) {
442
433
  const forwardToken = isFunction(forwardRef) ? forwardRef() : isBoolean(forwardRef) ? injectToken : forwardRef;
@@ -444,7 +435,10 @@ export class Injector {
444
435
  }
445
436
  const resolveFn = (injectMetadata.resolveAll == true) ? '_resolveAll' : '_resolve';
446
437
  const resolved = this[resolveFn](injectToken, parameterResolveArgument, { optional: injectMetadata.optional, forwardRef, forwardRefTypeHint: injectMetadata.forwardRefTypeHint }, context, getChain(injectToken));
447
- return isDefined(injectMetadata.mapper) ? injectMetadata.mapper(resolved) : resolved;
438
+ if (isDefined(injectMetadata.mapper)) {
439
+ return wrapInResolveError(() => injectMetadata.mapper(resolved), 'Error in inject mapper', getChain(injectToken));
440
+ }
441
+ return resolved;
448
442
  }
449
443
  resolveInjection(token, argument, options, context, injectIndex, chain) {
450
444
  return this._resolve(token, argument, options, context, chain.addInject(token, injectIndex));
@@ -463,7 +457,7 @@ export class Injector {
463
457
  return values;
464
458
  }
465
459
  getResolveContext(resolutionTag, resolveContext, chain) {
466
- const context = {
460
+ return {
467
461
  resolve: (token, argument, options) => this._resolve(token, argument, options ?? {}, resolveContext, chain.addToken(token)),
468
462
  resolveAll: (token, argument, options) => this._resolveAll(token, argument, options ?? {}, resolveContext, chain.addToken(token)),
469
463
  cancellationSignal: this.#disposeToken,
@@ -472,21 +466,19 @@ export class Injector {
472
466
  return resolveContext.resolutionContextData.get(resolutionTag);
473
467
  },
474
468
  };
475
- return context;
476
469
  }
477
470
  getAfterResolveContext(resolutionTag, resolveContext) {
478
- const context = {
471
+ return {
479
472
  cancellationSignal: this.#disposeToken,
480
473
  addDisposeHandler: this.#addDisposeHandler,
481
474
  get data() {
482
475
  return resolveContext.resolutionContextData.get(resolutionTag);
483
476
  },
484
477
  };
485
- return context;
486
478
  }
487
479
  getInjectionContext(resolveContext, resolveArgument, chain) {
488
480
  let injectIndex = 0;
489
- const context = {
481
+ return {
490
482
  injector: this,
491
483
  argument: resolveArgument,
492
484
  inject: (token, argument, options) => this.resolveInjection(token, argument, options ?? {}, resolveContext, injectIndex++, chain),
@@ -496,7 +488,6 @@ export class Injector {
496
488
  injectAllAsync: async (token, argument, options) => await this.resolveInjectionAllAsync(token, argument, options ?? {}, resolveContext, injectIndex++, chain),
497
489
  injectManyAsync: async (...tokens) => await this.resolveManyAsync(...tokens),
498
490
  };
499
- return context;
500
491
  }
501
492
  assertNotDisposed() {
502
493
  if (this.disposed) {
@@ -511,7 +502,7 @@ function addRegistration(registrations, registration) {
511
502
  if (isClassProvider(registration.provider)) {
512
503
  const injectable = reflectionRegistry.getMetadata(registration.provider.useClass)?.data.has(injectableMetadataSymbol) ?? false;
513
504
  if (!injectable) {
514
- throw new Error(`${registration.provider.useClass.name} is not injectable.`);
505
+ throw new RegistrationError(`${registration.provider.useClass.name} is not decorated with Injectable and cannot be registered.`);
515
506
  }
516
507
  }
517
508
  const multi = registration.options.multi ?? false;
@@ -519,7 +510,8 @@ function addRegistration(registrations, registration) {
519
510
  const hasExistingRegistration = isDefined(existingRegistration);
520
511
  const existingIsMulti = hasExistingRegistration && isArray(existingRegistration);
521
512
  if (hasExistingRegistration && (existingIsMulti != multi)) {
522
- throw new Error('Cannot mix multi and non-multi registrations.');
513
+ const tokenName = getTokenName(registration.token);
514
+ throw new RegistrationError(`Cannot mix multi and non-multi registrations for token: ${tokenName}.`);
523
515
  }
524
516
  if (multi && existingIsMulti) {
525
517
  existingRegistration.push(registration);
@@ -546,21 +538,31 @@ function postProcess(context) {
546
538
  }
547
539
  derefForwardRefs(context);
548
540
  for (const resolution of context.resolutions) {
549
- for (const afterResolveHandler of resolution.afterResolveRegistrations) {
550
- const returnValue = afterResolveHandler(resolution.argument, resolution.afterResolveContext);
551
- throwOnPromise(returnValue, 'registerAfterResolve()', resolution.chain);
552
- }
553
- if (!isTokenProvider(resolution.registration.provider) && isFunction(resolution.value?.[afterResolve])) {
554
- const returnValue = resolution.value[afterResolve](resolution.argument, resolution.afterResolveContext);
555
- throwOnPromise(returnValue, '[afterResolve]', resolution.chain);
556
- }
557
- if (isProviderWithInitializer(resolution.registration.provider)) {
558
- const returnValue = resolution.registration.provider.afterResolve?.(resolution.value, resolution.argument, resolution.afterResolveContext);
559
- throwOnPromise(returnValue, 'provider afterResolve handler', resolution.chain);
560
- }
561
- if (isDefined(resolution.registration.options.afterResolve)) {
562
- const returnValue = resolution.registration.options.afterResolve(resolution.value, resolution.argument, resolution.afterResolveContext);
563
- throwOnPromise(returnValue, 'registration afterResolve handler', resolution.chain);
541
+ const { registration, value, argument, afterResolveContext, afterResolveRegistrations, chain } = resolution;
542
+ const provider = registration.provider;
543
+ for (const afterResolveHandler of afterResolveRegistrations) {
544
+ wrapInResolveError(() => {
545
+ const returnValue = afterResolveHandler(argument, afterResolveContext);
546
+ throwOnPromise(returnValue, 'registerAfterResolve()', chain);
547
+ }, 'Error in registered afterResolve handler', chain);
548
+ }
549
+ if (!isTokenProvider(provider) && isFunction(value?.[afterResolve])) {
550
+ wrapInResolveError(() => {
551
+ const returnValue = value[afterResolve](argument, afterResolveContext);
552
+ throwOnPromise(returnValue, '[afterResolve] method', chain);
553
+ }, 'Error in [afterResolve] method', chain);
554
+ }
555
+ if (isProviderWithInitializer(provider)) {
556
+ wrapInResolveError(() => {
557
+ const returnValue = provider.afterResolve?.(value, argument, afterResolveContext);
558
+ throwOnPromise(returnValue, 'provider afterResolve handler', chain);
559
+ }, 'Error in providers afterResolve handler', chain);
560
+ }
561
+ if (isDefined(registration.options.afterResolve)) {
562
+ wrapInResolveError(() => {
563
+ const returnValue = registration.options.afterResolve(value, argument, afterResolveContext);
564
+ throwOnPromise(returnValue, 'registration afterResolve handler', resolution.chain);
565
+ }, `Error in registration afterResolve handler`, chain);
564
566
  }
565
567
  }
566
568
  }
@@ -570,29 +572,31 @@ async function postProcessAsync(context) {
570
572
  }
571
573
  derefForwardRefs(context);
572
574
  for (const resolution of context.resolutions) {
573
- for (const afterResolveHandler of resolution.afterResolveRegistrations) {
574
- await afterResolveHandler(resolution.argument, resolution.afterResolveContext);
575
+ const { registration, value, argument, afterResolveContext, afterResolveRegistrations, chain } = resolution;
576
+ const provider = registration.provider;
577
+ for (const afterResolveHandler of afterResolveRegistrations) {
578
+ await wrapInResolveErrorAsync(async () => await afterResolveHandler(argument, afterResolveContext), 'Error in registered async afterResolve handler', chain);
575
579
  }
576
- if (!isTokenProvider(resolution.registration.provider) && isFunction(resolution.value?.[afterResolve])) {
577
- await resolution.value[afterResolve](resolution.argument, resolution.afterResolveContext);
580
+ if (!isTokenProvider(provider) && isFunction(value?.[afterResolve])) {
581
+ await wrapInResolveErrorAsync(async () => await value[afterResolve](argument, afterResolveContext), 'Error in async [afterResolve] method', chain);
578
582
  }
579
- if (isProviderWithInitializer(resolution.registration.provider)) {
580
- await resolution.registration.provider.afterResolve?.(resolution.value, resolution.argument, resolution.afterResolveContext);
583
+ if (isProviderWithInitializer(provider)) {
584
+ await wrapInResolveErrorAsync(async () => await provider.afterResolve?.(value, argument, afterResolveContext), 'Error in providers async afterResolve handler', chain);
581
585
  }
582
- if (isDefined(resolution.registration.options.afterResolve)) {
583
- await resolution.registration.options.afterResolve(resolution.value, resolution.argument, resolution.afterResolveContext);
586
+ if (isDefined(registration.options.afterResolve)) {
587
+ await wrapInResolveErrorAsync(() => registration.options.afterResolve(value, argument, afterResolveContext), 'Error in registration async afterResolve handler', chain);
584
588
  }
585
589
  }
586
590
  }
587
- function resolveArgumentIdentity(registration, resolveArgument) {
591
+ function resolveArgumentIdentity(registration, resolveArgument, chain) {
588
592
  if (isDefined(registration.options.argumentIdentityProvider) && ((registration.options.lifecycle == 'resolution') || (registration.options.lifecycle == 'singleton'))) {
589
- return registration.options.argumentIdentityProvider(resolveArgument);
593
+ return wrapInResolveError(() => registration.options.argumentIdentityProvider(resolveArgument), 'Error in argumentIdentityProvider', chain);
590
594
  }
591
595
  return resolveArgument;
592
596
  }
593
597
  function setResolving(token, context, chain) {
594
598
  if (context.resolving.has(token)) {
595
- throw new ResolveError('Circular dependency to itself detected. Please check your registrations and providers. ForwardRef might be a solution.', chain);
599
+ throw new ResolveError('Circular dependency detected. Use forwardRef for circular dependencies between classes.', chain);
596
600
  }
597
601
  context.resolving.add(token);
598
602
  }
@@ -601,12 +605,12 @@ function deleteResolving(token, context) {
601
605
  }
602
606
  function throwOnPromise(value, type, chain) {
603
607
  if (isPromise(value)) {
604
- throw new ResolveError(`Cannot evaluate async ${type} in synchronous resolve, use resolveAsync() instead.`, chain);
608
+ throw new ResolveError(`Cannot evaluate async ${type} in a synchronous resolve. Use resolveAsync() instead.`, chain);
605
609
  }
606
610
  }
607
611
  function checkOverflow(chain, context) {
608
612
  if ((chain.length > 100) || (++context.resolves > 7500)) {
609
- throw new ResolveError('Resolve stack overflow. This can happen on circular dependencies with transient lifecycles and self reference. Use scoped or singleton lifecycle or forwardRef instead.', chain);
613
+ throw new ResolveError('Resolve stack overflow. This may indicate a circular dependency with transient lifecycles. Consider using a scoped or singleton lifecycle, or forwardRef.', chain);
610
614
  }
611
615
  }
612
616
  function derefForwardRefs(context) {
@@ -615,10 +619,31 @@ function derefForwardRefs(context) {
615
619
  continue;
616
620
  }
617
621
  for (const [key, value] of objectEntries(resolution.value)) {
618
- if (!context.forwardRefs.has(value)) {
619
- continue;
622
+ if (context.forwardRefs.has(value)) {
623
+ resolution.value[key] = ForwardRef.deref(value);
620
624
  }
621
- resolution.value[key] = ForwardRef.deref(value);
622
625
  }
623
626
  }
624
627
  }
628
+ function wrapInResolveError(action, errorMessage, chain) {
629
+ try {
630
+ return action();
631
+ }
632
+ catch (error) {
633
+ if (error instanceof ResolveError) {
634
+ throw error;
635
+ }
636
+ throw new ResolveError(errorMessage, chain, error);
637
+ }
638
+ }
639
+ async function wrapInResolveErrorAsync(action, errorMessage, chain) {
640
+ try {
641
+ return await action();
642
+ }
643
+ catch (error) {
644
+ if (error instanceof ResolveError) {
645
+ throw error;
646
+ }
647
+ throw new ResolveError(errorMessage, chain, error);
648
+ }
649
+ }
@@ -0,0 +1,8 @@
1
+ import { CustomError } from '../errors/custom.error.js';
2
+ /**
3
+ * Represents an error that occurs during the provider registration phase.
4
+ * This indicates a configuration or setup issue, rather than a runtime resolution failure.
5
+ */
6
+ export declare class RegistrationError extends CustomError {
7
+ constructor(message: string);
8
+ }
@@ -0,0 +1,11 @@
1
+ import { CustomError } from '../errors/custom.error.js';
2
+ /**
3
+ * Represents an error that occurs during the provider registration phase.
4
+ * This indicates a configuration or setup issue, rather than a runtime resolution failure.
5
+ */
6
+ export class RegistrationError extends CustomError {
7
+ constructor(message) {
8
+ super({ message });
9
+ this.name = 'RegistrationError';
10
+ }
11
+ }
@@ -1,12 +1,9 @@
1
1
  import { CustomError } from '../errors/custom.error.js';
2
- import { isDefined } from '../utils/type-guards.js';
3
2
  export class ResolveError extends CustomError {
4
3
  constructor(message, chain, cause) {
5
- const causeMessage = isDefined(cause) ? `\n cause: ${cause.message}` : '';
6
4
  super({
7
- message: `${message}${causeMessage}\n chain: ${chain.format(15)}`,
5
+ message: `${message}\n chain: ${chain.format(15)}`,
8
6
  cause,
9
- stack: cause?.stack,
10
7
  });
11
8
  }
12
9
  }
package/orm/query.d.ts CHANGED
@@ -166,26 +166,42 @@ export type ComparisonGeoDistanceQuery = {
166
166
  export type FullTextSearchQuery<T = any> = {
167
167
  $fts: {
168
168
  fields: readonly (Extract<keyof T, string>)[];
169
- query: string | SQL<string>;
169
+ text: string | SQL<string>;
170
170
  /**
171
171
  * The search method to use.
172
172
  * - 'vector': (Default) Standard full-text search using tsvector and tsquery.
173
- * - 'similarity': Trigram-based similarity search using the pg_trgm extension.
174
- */
175
- method?: 'vector' | 'similarity';
176
- /**
177
- * The parser to use for the query. Only applicable for 'vector' method.
178
- */
179
- parser?: FtsParser;
180
- /**
181
- * The text search configuration (e.g., 'english', 'simple'). Can also be a SQL object for dynamic configuration. Only applicable for 'vector' method.
182
- */
183
- language?: string | SQL<string>;
184
- /**
185
- * Assigns weights to fields for ranking.
186
- * Keys are field names from `fields`, values are 'A', 'B', 'C', or 'D'.
187
- * Fields without a specified weight will use the default. Only applicable for 'vector' method.
173
+ * - 'trigram': Trigram-based similarity search using the pg_trgm extension.
188
174
  */
189
- weights?: Partial<Record<Extract<keyof T, string>, 'A' | 'B' | 'C' | 'D'>>;
175
+ method?: 'vector' | 'trigram';
176
+ vector?: {
177
+ /**
178
+ * The parser to use for the query. Only applicable for 'vector' method.
179
+ */
180
+ parser?: FtsParser;
181
+ /**
182
+ * The text search configuration (e.g., 'english', 'simple'). Can also be a SQL object for dynamic configuration. Only applicable for 'vector' method.
183
+ */
184
+ language?: string | SQL<string>;
185
+ /**
186
+ * Assigns weights to fields for ranking.
187
+ * Keys are field names from `fields`, values are 'A', 'B', 'C', or 'D'.
188
+ * Fields without a specified weight will use the default. Only applicable for 'vector' method.
189
+ */
190
+ weights?: Partial<Record<Extract<keyof T, string>, 'A' | 'B' | 'C' | 'D'>>;
191
+ };
192
+ trigram?: {
193
+ /**
194
+ * Type of similarity to use for 'trigram' search.
195
+ * - 'normal': Standard trigram similarity (default).
196
+ * - 'word': Word-based similarity.
197
+ * - 'strict-word': Strict word-based similarity.
198
+ * @default 'normal'
199
+ */
200
+ type?: 'phrase' | 'word' | 'strict-word';
201
+ /**
202
+ * Threshold for similarity matching (0 to 1). Only applicable for 'trigram' method.
203
+ */
204
+ threshold?: number | SQL<number>;
205
+ };
190
206
  };
191
207
  };
@@ -3,12 +3,12 @@
3
3
  * Defines types used by ORM repositories for operations like loading, updating, and creating entities.
4
4
  * Includes types for ordering, loading options, and entity data structures for create/update operations.
5
5
  */
6
- import type { Paths, Record, TypedOmit } from '../types/index.js';
6
+ import type { Paths, Record, SimplifyObject, TypedOmit } from '../types/index.js';
7
7
  import type { UntaggedDeep } from '../types/tagged.js';
8
8
  import type { SQL, SQLWrapper } from 'drizzle-orm';
9
9
  import type { PartialDeep } from 'type-fest';
10
10
  import type { Entity, EntityMetadata, EntityWithoutMetadata } from './entity.js';
11
- import type { Query } from './query.js';
11
+ import type { FullTextSearchQuery, Query } from './query.js';
12
12
  import type { TsHeadlineOptions } from './sqls.js';
13
13
  type WithSql<T> = {
14
14
  [P in keyof T]: T[P] extends Record ? WithSql<T[P]> : (T[P] | SQL);
@@ -84,31 +84,44 @@ export type HighlightOptions<T extends EntityWithoutMetadata> = {
84
84
  * Options for the `search` method.
85
85
  * @template T - The entity type.
86
86
  */
87
- export type SearchOptions<T extends EntityWithoutMetadata> = Omit<LoadManyOptions<T>, 'order'> & {
87
+ export type SearchOptions<T extends EntityWithoutMetadata> = SimplifyObject<FullTextSearchQuery<T>['$fts'] & TypedOmit<LoadManyOptions<T>, 'order'> & {
88
88
  /**
89
89
  * An additional filter to apply to the search query.
90
90
  */
91
91
  filter?: Query<T>;
92
+ /**
93
+ * How to order the search results.
94
+ */
95
+ order?: Order<T> | ((columns: {
96
+ score: SQL | SQL.Aliased<number>;
97
+ }) => Order<T>);
98
+ /**
99
+ * Whether to include a relevance score with each result. Only applicable for vector searches.
100
+ * - If `true`, the default score is included.
101
+ * - If a function is provided, it customizes the score calculation using the original score.
102
+ * - If no order is specified, results are ordered by score descending, when score is enabled.
103
+ * @default true
104
+ */
105
+ score?: boolean | ((originalScore: SQL<number>) => SQL<number>);
92
106
  /**
93
107
  * Enable and configure ranking of search results.
94
108
  * - If `true` (default), results are ordered by score descending using default ranking options.
95
109
  * - If an `RankOptions` object is provided, ranking is customized.
96
- * - If an `SQL` object is provided, it's used as a custom scoring expression.
97
110
  * @default true
98
111
  */
99
- rank?: boolean | RankOptions | SQL;
112
+ rank?: boolean | RankOptions;
100
113
  /**
101
114
  * Enable and configure highlighting of search results.
102
115
  */
103
116
  highlight?: TargetColumnPaths<T> | SQL<string> | HighlightOptions<T>;
104
- };
117
+ }>;
105
118
  /**
106
119
  * Represents a single result from a full-text search operation.
107
120
  * @template T - The entity type.
108
121
  */
109
122
  export type SearchResult<T extends EntityWithoutMetadata> = {
110
123
  entity: T;
111
- score: number;
124
+ score?: number;
112
125
  highlight?: string;
113
126
  };
114
127
  /**
@@ -17,4 +17,4 @@ import type { ColumnDefinition, PgTableFromType } from './types.js';
17
17
  export declare function convertQuery(query: Query, table: PgTableFromType, columnDefinitionsMap: Map<string, ColumnDefinition>): SQL;
18
18
  export declare function getTsQuery(text: string | SQL, language: string | SQL, parser: FtsParser): SQL;
19
19
  export declare function getTsVector(fields: readonly string[], language: string | SQL, table: PgTableFromType, columnDefinitionsMap: Map<string, ColumnDefinition>, weights?: Partial<Record<string, 'A' | 'B' | 'C' | 'D'>>): SQL;
20
- export declare function getSimilaritySearchExpression(fields: readonly string[], table: PgTableFromType, columnDefinitionsMap: Map<string, ColumnDefinition>): SQL;
20
+ export declare function getColumnConcatenation(fields: readonly string[], table: PgTableFromType, columnDefinitionsMap: Map<string, ColumnDefinition>): SQL;
@@ -61,13 +61,13 @@ export function convertQuery(query, table, columnDefinitionsMap) {
61
61
  const method = ftsQuery.method ?? 'vector';
62
62
  const sqlValue = match(method)
63
63
  .with('vector', () => {
64
- const tsquery = getTsQuery(ftsQuery.query, ftsQuery.language ?? 'simple', ftsQuery.parser ?? 'raw');
65
- const tsvector = getTsVector(ftsQuery.fields, ftsQuery.language ?? 'simple', table, columnDefinitionsMap, ftsQuery.weights);
64
+ const tsquery = getTsQuery(ftsQuery.text, ftsQuery.vector?.language ?? 'simple', ftsQuery.vector?.parser ?? 'raw');
65
+ const tsvector = getTsVector(ftsQuery.fields, ftsQuery.vector?.language ?? 'simple', table, columnDefinitionsMap, ftsQuery.vector?.weights);
66
66
  return sql `${tsvector} @@ ${tsquery}`;
67
67
  })
68
- .with('similarity', () => {
69
- const searchExpression = getSimilaritySearchExpression(ftsQuery.fields, table, columnDefinitionsMap);
70
- return isSimilar(searchExpression, ftsQuery.query);
68
+ .with('trigram', () => {
69
+ const searchExpression = getColumnConcatenation(ftsQuery.fields, table, columnDefinitionsMap);
70
+ return isSimilar(searchExpression, ftsQuery.text);
71
71
  })
72
72
  .exhaustive();
73
73
  conditions.push(sqlValue);
@@ -214,10 +214,10 @@ export function getTsVector(fields, language, table, columnDefinitionsMap, weigh
214
214
  }), sql ` || `);
215
215
  return tsvector;
216
216
  }
217
- export function getSimilaritySearchExpression(fields, table, columnDefinitionsMap) {
217
+ export function getColumnConcatenation(fields, table, columnDefinitionsMap) {
218
218
  const columns = fields.map((field) => {
219
219
  const columnDef = assertDefinedPass(columnDefinitionsMap.get(field), `Could not map property ${field} to column.`);
220
220
  return table[columnDef.name];
221
221
  });
222
- return sql.join(columns, sql ` || ' ' || `);
222
+ return sql `(${sql.join(columns, sql ` || ' ' || `)})`;
223
223
  }
@@ -1,10 +1,10 @@
1
1
  import { SQL } from 'drizzle-orm';
2
- import type { PgColumn, PgInsertValue, PgUpdateSetSource } from 'drizzle-orm/pg-core';
2
+ import type { PgColumn, PgInsertValue, PgSelectBuilder, PgUpdateSetSource, SelectedFields } from 'drizzle-orm/pg-core';
3
3
  import { afterResolve, resolveArgumentType, type Resolvable } from '../../injector/interfaces.js';
4
- import type { DeepPartial, OneOrMany, Paths, Type, UntaggedDeep } from '../../types/index.js';
4
+ import type { DeepPartial, Function, OneOrMany, Paths, Record, Type, UntaggedDeep } from '../../types/index.js';
5
5
  import { Entity, type EntityMetadataAttributes, type EntityType, type EntityWithoutMetadata } from '../entity.js';
6
6
  import type { FullTextSearchQuery, Query } from '../query.js';
7
- import type { EntityMetadataUpdate, EntityUpdate, LoadManyOptions, LoadOptions, NewEntity, Order, SearchOptions, SearchResult, TargetColumnPaths } from '../repository.types.js';
7
+ import type { EntityMetadataUpdate, EntityUpdate, LoadManyOptions, LoadOptions, NewEntity, Order, SearchOptions, SearchResult, TargetColumn, TargetColumnPaths } from '../repository.types.js';
8
8
  import type { Database } from './database.js';
9
9
  import type { PgTransaction } from './transaction.js';
10
10
  import { Transactional } from './transactional.js';
@@ -40,6 +40,8 @@ export declare class EntityRepository<T extends Entity | EntityWithoutMetadata =
40
40
  [afterResolve](): void;
41
41
  private expirationLoop;
42
42
  protected getTransactionalContextData(): EntityRepositoryContext;
43
+ vectorSearch(options: SearchOptions<T>): Promise<SearchResult<T>[]>;
44
+ trigramSearch(options: SearchOptions<T>): Promise<SearchResult<T>[]>;
43
45
  /**
44
46
  * Performs a full-text search and returns entities ranked by relevance.
45
47
  * This method is a convenience wrapper around `loadManyByQuery` with the `$fts` operator.
@@ -47,7 +49,7 @@ export declare class EntityRepository<T extends Entity | EntityWithoutMetadata =
47
49
  * @param options Search options including ranking, and highlighting configuration.
48
50
  * @returns A promise that resolves to an array of search results, including the entity, score, and optional highlight.
49
51
  */
50
- search(query: FullTextSearchQuery<T>['$fts'], options?: SearchOptions<T>): Promise<SearchResult<T>[]>;
52
+ search(_query: FullTextSearchQuery<T>['$fts'], _options?: SearchOptions<T>): Promise<SearchResult<T>[]>;
51
53
  /**
52
54
  * Loads a single entity by its ID.
53
55
  * Throws `NotFoundError` if the entity is not found.
@@ -445,6 +447,8 @@ export declare class EntityRepository<T extends Entity | EntityWithoutMetadata =
445
447
  generated: undefined;
446
448
  }, {}, {}>;
447
449
  }>, "limit" | "where">;
450
+ applySelect<TApplyTo extends Record<'select' | 'selectDistinct' | 'selectDistinctOn', Function<any[], PgSelectBuilder<any, any>>>>(applyTo: TApplyTo, distinct?: boolean | TargetColumn<T>[]): PgSelectBuilder<undefined, ReturnType<TApplyTo['selectDistinct']> extends PgSelectBuilder<any, infer TResult> ? TResult : never>;
451
+ applySelect<TApplyTo extends Record<'select' | 'selectDistinct' | 'selectDistinctOn', Function<any[], PgSelectBuilder<any, any>>>, TSelection extends SelectedFields>(applyTo: TApplyTo, selection: TSelection, distinct?: boolean | TargetColumn<T>[]): PgSelectBuilder<TSelection, ReturnType<TApplyTo['selectDistinct']> extends PgSelectBuilder<any, infer TResult> ? TResult : never>;
448
452
  protected getAttributesUpdate(attributes: SQL | EntityMetadataAttributes | undefined): SQL<unknown> | undefined;
449
453
  protected _mapManyToEntity(columns: InferSelect[], transformContext: TransformContext): Promise<T[]>;
450
454
  protected _mapToEntity(columns: InferSelect, transformContext: TransformContext): Promise<T>;
@@ -8,7 +8,7 @@ import { and, asc, count, desc, eq, inArray, isNull, isSQLWrapper, lte, or, SQL,
8
8
  import { match, P } from 'ts-pattern';
9
9
  import { CancellationSignal } from '../../cancellation/token.js';
10
10
  import { NotFoundError } from '../../errors/not-found.error.js';
11
- import { NotSupportedError } from '../../errors/not-supported.error.js';
11
+ import { NotImplementedError } from '../../errors/not-implemented.error.js';
12
12
  import { Singleton } from '../../injector/decorators.js';
13
13
  import { inject, injectArgument } from '../../injector/inject.js';
14
14
  import { afterResolve, resolveArgumentType } from '../../injector/interfaces.js';
@@ -20,16 +20,17 @@ import { importSymmetricKey } from '../../utils/cryptography.js';
20
20
  import { fromDeepObjectEntries, fromEntries, objectEntries } from '../../utils/object/object.js';
21
21
  import { cancelableTimeout } from '../../utils/timing.js';
22
22
  import { tryIgnoreAsync } from '../../utils/try-ignore.js';
23
- import { assertDefined, assertDefinedPass, isArray, isBoolean, isDefined, isInstanceOf, isString, isUndefined } from '../../utils/type-guards.js';
23
+ import { assertDefined, assertDefinedPass, isArray, isBoolean, isDefined, isFunction, isInstanceOf, isString, isUndefined } from '../../utils/type-guards.js';
24
24
  import { typeExtends } from '../../utils/type/index.js';
25
25
  import { millisecondsPerSecond } from '../../utils/units.js';
26
26
  import { Entity } from '../entity.js';
27
- import { isSimilar, similarity, TRANSACTION_TIMESTAMP, tsHeadline, tsRankCd } from '../sqls.js';
27
+ import { TRANSACTION_TIMESTAMP, tsHeadline, tsRankCd } from '../sqls.js';
28
28
  import { getColumnDefinitions, getColumnDefinitionsMap, getDrizzleTableFromType } from './drizzle/schema-converter.js';
29
- import { convertQuery, getSimilaritySearchExpression, getTsQuery, getTsVector } from './query-converter.js';
29
+ import { convertQuery, getColumnConcatenation, getTsQuery, getTsVector } from './query-converter.js';
30
30
  import { ENCRYPTION_SECRET } from './tokens.js';
31
31
  import { getTransactionalContextData, injectTransactional, injectTransactionalAsync, isInTransactionalContext, Transactional } from './transactional.js';
32
32
  const searchScoreColumn = '__tsl_score';
33
+ const searchDistanceColumn = '__tsl_distance';
33
34
  const searchHighlightColumn = '__tsl_highlight';
34
35
  export const repositoryType = Symbol('repositoryType');
35
36
  /**
@@ -95,97 +96,59 @@ let EntityRepository = class EntityRepository extends Transactional {
95
96
  };
96
97
  return context;
97
98
  }
98
- /**
99
- * Performs a full-text search and returns entities ranked by relevance.
100
- * This method is a convenience wrapper around `loadManyByQuery` with the `$fts` operator.
101
- * @param query The search query using the `$fts` operator.
102
- * @param options Search options including ranking, and highlighting configuration.
103
- * @returns A promise that resolves to an array of search results, including the entity, score, and optional highlight.
104
- */
105
- async search(query, options) {
106
- const { method = 'vector' } = query;
107
- let whereClause;
108
- let rankExpression;
109
- let highlightExpression;
110
- const rankEnabled = options?.rank ?? true;
111
- match(method)
112
- .with('similarity', () => {
113
- if (isDefined(query.weights) || isDefined(query.parser) || isDefined(options?.highlight)) {
114
- throw new NotSupportedError('`weights`, `parser`, and `highlight` are not applicable to similarity search.');
115
- }
116
- const searchExpression = getSimilaritySearchExpression(query.fields, this.table, this.#columnDefinitionsMap);
117
- whereClause = isSimilar(searchExpression, query.query);
118
- if (rankEnabled) {
119
- rankExpression = isInstanceOf(options?.rank, SQL)
120
- ? options.rank
121
- : similarity(searchExpression, query.query);
122
- }
123
- })
124
- .with('vector', () => {
125
- const { language = 'simple' } = query;
126
- const languageSql = isString(language) ? language : sql `${language}`;
127
- const tsquery = getTsQuery(query.query, languageSql, query.parser ?? 'raw');
128
- const tsvector = getTsVector(query.fields, languageSql, this.table, this.#columnDefinitionsMap, query.weights);
129
- whereClause = sql `${tsvector} @@ ${tsquery}`;
130
- if (rankEnabled) {
131
- if (isInstanceOf(options?.rank, SQL)) {
132
- rankExpression = options.rank;
133
- }
134
- else {
135
- const rankOptions = isBoolean(options?.rank) ? undefined : options?.rank;
136
- rankExpression = tsRankCd(tsvector, tsquery, rankOptions);
137
- }
138
- }
139
- if (isDefined(options?.highlight)) {
140
- const { source, ...headlineOptions } = (isString(options.highlight) || isInstanceOf(options.highlight, SQL))
141
- ? { source: options.highlight }
142
- : options.highlight;
143
- const document = match(source)
144
- .with(P.instanceOf(SQL), (s) => s)
145
- .otherwise((paths) => {
146
- const columns = this.getColumns(paths);
147
- return sql.join(columns, sql ` || ' ' || `);
148
- });
149
- highlightExpression = tsHeadline(languageSql, document, tsquery, headlineOptions);
150
- }
151
- })
152
- .exhaustive();
153
- if (isDefined(options?.filter)) {
154
- const filter = this.convertQuery(options.filter);
155
- whereClause = and(whereClause, filter);
156
- }
99
+ async vectorSearch(options) {
100
+ const { vector: { language = 'simple' } = {} } = options;
101
+ const languageSql = isString(language) ? language : sql `${language}`;
102
+ const tsquery = getTsQuery(options.text, languageSql, options.vector?.parser ?? 'raw');
103
+ const tsvector = getTsVector(options.fields, languageSql, this.table, this.#columnDefinitionsMap, options.vector?.weights);
104
+ const rawScore = (options.score != false) ? tsRankCd(tsvector, tsquery, isBoolean(options.rank) ? undefined : options.rank) : undefined;
105
+ const score = (isFunction(options.score) ? options.score(rawScore) : rawScore)?.as(searchScoreColumn);
106
+ const vectorClause = sql `${tsvector} @@ ${tsquery}`;
157
107
  const selection = fromEntries(this.#columnDefinitions.map((column) => [column.name, this.getColumn(column)]));
158
- if (isDefined(rankExpression)) {
159
- selection[searchScoreColumn] = rankExpression.as(searchScoreColumn);
108
+ if (isDefined(score)) {
109
+ selection[searchScoreColumn] = score;
160
110
  }
161
- if (isDefined(highlightExpression)) {
162
- selection[searchHighlightColumn] = highlightExpression.as(searchHighlightColumn);
111
+ if (isDefined(options.highlight)) {
112
+ const { source, ...headlineOptions } = (isString(options.highlight) || isInstanceOf(options.highlight, SQL))
113
+ ? { source: options.highlight }
114
+ : options.highlight;
115
+ const document = match(source)
116
+ .with(P.instanceOf(SQL), (s) => s)
117
+ .otherwise((paths) => {
118
+ const columns = this.getColumns(paths);
119
+ return sql.join(columns, sql ` || ' ' || `);
120
+ });
121
+ selection[searchHighlightColumn] = tsHeadline(languageSql, document, tsquery, headlineOptions).as(searchHighlightColumn);
163
122
  }
164
- let dbQuery = match(options?.distinct ?? false)
165
- .with(false, () => this.session.select(selection))
166
- .with(true, () => this.session.selectDistinct(selection))
167
- .otherwise((targets) => {
168
- const ons = targets.map((target) => isString(target) ? this.getColumn(target) : target);
169
- return this.session.selectDistinctOn(ons, selection);
170
- })
123
+ const whereClause = isDefined(options.filter)
124
+ ? and(this.convertQuery(options.filter), vectorClause)
125
+ : vectorClause;
126
+ let dbQuery = this
127
+ .applySelect(this.session, selection, options.distinct)
171
128
  .from(this.#table)
172
129
  .where(whereClause)
173
130
  .$dynamic();
174
- if (isDefined(options?.offset)) {
131
+ if (isDefined(options.offset)) {
175
132
  dbQuery = dbQuery.offset(options.offset);
176
133
  }
177
- if (isDefined(options?.limit)) {
134
+ if (isDefined(options.limit)) {
178
135
  dbQuery = dbQuery.limit(options.limit);
179
136
  }
180
- if (rankEnabled) {
181
- const orderByExpressions = [];
182
- if (isArray(options?.distinct)) {
183
- const ons = options.distinct.map((target) => isString(target) ? this.getColumn(target) : target.getSQL());
184
- orderByExpressions.push(...ons);
185
- }
186
- orderByExpressions.push(desc(sql.identifier(searchScoreColumn)));
187
- dbQuery = dbQuery.orderBy(...orderByExpressions);
137
+ const orderByExpressions = [];
138
+ if (isDefined(options.order)) {
139
+ const order = isFunction(options.order)
140
+ ? options.order({
141
+ get score() {
142
+ return assertDefinedPass(score, 'Score is disabled.');
143
+ },
144
+ })
145
+ : options.order;
146
+ orderByExpressions.push(...this.convertOrderBy(order));
188
147
  }
148
+ else if (isDefined(score)) {
149
+ orderByExpressions.push(desc(score));
150
+ }
151
+ dbQuery = dbQuery.orderBy(...orderByExpressions);
189
152
  const transformContext = await this.getTransformContext();
190
153
  const rows = await dbQuery;
191
154
  return await toArrayAsync(mapAsync(rows, async ({ [searchScoreColumn]: score, [searchHighlightColumn]: highlight, ...row }) => ({
@@ -194,6 +157,67 @@ let EntityRepository = class EntityRepository extends Transactional {
194
157
  highlight: highlight,
195
158
  })));
196
159
  }
160
+ async trigramSearch(options) {
161
+ const distanceOperator = match(options.trigram?.type ?? 'phrase')
162
+ .with('phrase', () => '<->')
163
+ .with('word', () => '<<->')
164
+ .with('strict-word', () => '<<<->')
165
+ .exhaustive();
166
+ const distanceThresholdOperator = match(options.trigram?.type ?? 'phrase')
167
+ .with('phrase', () => '%')
168
+ .with('word', () => '<%')
169
+ .with('strict-word', () => '<<%')
170
+ .exhaustive();
171
+ // TODO: set similarity_threshold, word_similarity_threshold, strict_word_similarity_threshold
172
+ const searchExpression = getColumnConcatenation(options.fields, this.table, this.#columnDefinitionsMap);
173
+ const distance = sql `(${options.text} ${sql.raw(distanceOperator)} ${searchExpression})`.as(searchDistanceColumn);
174
+ const trigramClause = sql `(${options.text} ${sql.raw(distanceThresholdOperator)} ${searchExpression})`;
175
+ const selection = fromEntries(this.#columnDefinitions.map((column) => [column.name, this.getColumn(column)]));
176
+ selection[searchDistanceColumn] = distance;
177
+ const whereClause = isDefined(options.filter)
178
+ ? and(this.convertQuery(options.filter), trigramClause)
179
+ : trigramClause;
180
+ let dbQuery = this
181
+ .applySelect(this.session, selection, options.distinct)
182
+ .from(this.#table)
183
+ .where(whereClause)
184
+ .$dynamic();
185
+ if (isDefined(options.offset)) {
186
+ dbQuery = dbQuery.offset(options.offset);
187
+ }
188
+ if (isDefined(options.limit)) {
189
+ dbQuery = dbQuery.limit(options.limit);
190
+ }
191
+ const orderByExpressions = [];
192
+ if (isDefined(options.order)) {
193
+ const order = isFunction(options.order)
194
+ ? options.order({ score: sql `1 - ${distance}` })
195
+ : options.order;
196
+ orderByExpressions.push(...this.convertOrderBy(order));
197
+ }
198
+ else if (options.rank != false) {
199
+ orderByExpressions.push(distance);
200
+ }
201
+ dbQuery = dbQuery.orderBy(...orderByExpressions);
202
+ console.log(dbQuery.toSQL());
203
+ const transformContext = await this.getTransformContext();
204
+ const rows = await dbQuery;
205
+ return await toArrayAsync(mapAsync(rows, async ({ [searchDistanceColumn]: distance, [searchHighlightColumn]: highlight, ...row }) => ({
206
+ entity: await this._mapToEntity(row, transformContext),
207
+ score: (1 - distance),
208
+ highlight: highlight,
209
+ })));
210
+ }
211
+ /**
212
+ * Performs a full-text search and returns entities ranked by relevance.
213
+ * This method is a convenience wrapper around `loadManyByQuery` with the `$fts` operator.
214
+ * @param query The search query using the `$fts` operator.
215
+ * @param options Search options including ranking, and highlighting configuration.
216
+ * @returns A promise that resolves to an array of search results, including the entity, score, and optional highlight.
217
+ */
218
+ async search(_query, _options) {
219
+ throw new NotImplementedError('EntityRepository.search is not implemented yet.');
220
+ }
197
221
  /**
198
222
  * Loads a single entity by its ID.
199
223
  * Throws `NotFoundError` if the entity is not found.
@@ -288,13 +312,7 @@ let EntityRepository = class EntityRepository extends Transactional {
288
312
  */
289
313
  async loadManyByQuery(query, options) {
290
314
  const sqlQuery = this.convertQuery(query);
291
- let dbQuery = match(options?.distinct ?? false)
292
- .with(false, () => this.session.select())
293
- .with(true, () => this.session.selectDistinct())
294
- .otherwise((targets) => {
295
- const ons = targets.map((target) => isString(target) ? this.getColumn(target) : target);
296
- return this.session.selectDistinctOn(ons);
297
- })
315
+ let dbQuery = this.applySelect(this.session, options?.distinct)
298
316
  .from(this.#table)
299
317
  .where(sqlQuery)
300
318
  .$dynamic();
@@ -957,6 +975,19 @@ let EntityRepository = class EntityRepository extends Transactional {
957
975
  .where(sqlQuery)
958
976
  .limit(1);
959
977
  }
978
+ applySelect(applyTo, selectionOrDistinct, distinctOrNothing) {
979
+ const firstParameterIsDistinct = isBoolean(selectionOrDistinct) || isArray(selectionOrDistinct);
980
+ const selection = firstParameterIsDistinct ? undefined : selectionOrDistinct;
981
+ const distinct = firstParameterIsDistinct ? selectionOrDistinct : distinctOrNothing;
982
+ const selectBuilder = match(distinct ?? false)
983
+ .with(false, () => applyTo.select(selection))
984
+ .with(true, () => applyTo.selectDistinct(selection))
985
+ .otherwise((targets) => {
986
+ const ons = targets.map((target) => isString(target) ? this.getColumn(target) : target);
987
+ return applyTo.selectDistinctOn(ons, selection);
988
+ });
989
+ return selectBuilder;
990
+ }
960
991
  getAttributesUpdate(attributes) {
961
992
  if (isUndefined(attributes)) {
962
993
  return undefined;
package/orm/sqls.d.ts CHANGED
@@ -116,7 +116,9 @@ export declare function greatest<T extends (Column | SQL | number)[]>(...values:
116
116
  [P in keyof T]: T[P] extends number ? Exclude<T[P], number> | SQL<number> : T[P];
117
117
  }[number]>>;
118
118
  export declare function greatest<T>(...values: T[]): SQL<SelectResultField<T>>;
119
- export declare function toTsVector(language: string | SQL, column: Column | SQLChunk): SQL<string>;
119
+ export declare function unnest<T>(array: SQL<readonly T[]>): SQL<T>;
120
+ export declare function toTsVector(language: string | SQL, text: string | Column | SQLChunk): SQL<string>;
121
+ export declare function tsvectorToArray(tsvector: SQL): SQL<string[]>;
120
122
  /**
121
123
  * Creates a PostgreSQL `to_tsquery` function call.
122
124
  * This function parses text into a tsquery, respecting operators like & (AND), | (OR), and ! (NOT).
@@ -183,7 +185,7 @@ export declare function tsRankCd(tsvector: SQL, tsquery: SQL, options?: {
183
185
  * Cross-Site Scripting (XSS) vulnerabilities. Ensure the `document` content is
184
186
  * properly sanitized before rendering it in a browser if it comes from an untrusted source.
185
187
  */
186
- export declare function tsHeadline(language: string | SQL, document: string | SQL | AnyColumn, tsquery: SQL, options?: TsHeadlineOptions): SQL<string>;
188
+ export declare function tsHeadline(language: string | SQL, document: string | SQL | SQL.Aliased | AnyColumn, tsquery: SQL, options?: TsHeadlineOptions): SQL<string>;
187
189
  /**
188
190
  * Creates a PostgreSQL `similarity` function call (from pg_trgm extension).
189
191
  * Calculates the similarity between two strings based on trigram matching.
@@ -191,7 +193,15 @@ export declare function tsHeadline(language: string | SQL, document: string | SQ
191
193
  * @param right The second text value or expression to compare against.
192
194
  * @returns A Drizzle SQL object representing the similarity score (0 to 1).
193
195
  */
194
- export declare function similarity(left: string | SQL | AnyColumn, right: string | SQL | AnyColumn): SQL<number>;
196
+ export declare function similarity(left: string | SQL | SQL.Aliased | AnyColumn, right: string | SQL | SQL.Aliased | AnyColumn): SQL<number>;
197
+ /**
198
+ * Creates a PostgreSQL `word_similarity` function call (from pg_trgm extension).
199
+ * Calculates the word similarity between two strings based on trigram matching.
200
+ * @param left The first text column or expression.
201
+ * @param right The second text value or expression to compare against.
202
+ * @returns A Drizzle SQL object representing the similarity score (0 to 1).
203
+ */
204
+ export declare function wordSimilarity(left: string | SQL | SQL.Aliased | AnyColumn, right: string | SQL | SQL.Aliased | AnyColumn): SQL<number>;
195
205
  /**
196
206
  * Creates a PostgreSQL `%` operator call (from pg_trgm extension) for similarity check.
197
207
  * Returns true if the similarity between the two arguments is greater than the current similarity threshold.
@@ -199,7 +209,7 @@ export declare function similarity(left: string | SQL | AnyColumn, right: string
199
209
  * @param right The text value or expression to compare against.
200
210
  * @returns A Drizzle SQL object representing a boolean similarity check.
201
211
  */
202
- export declare function isSimilar(left: string | SQL | AnyColumn, right: string | SQL | AnyColumn): SQL<boolean>;
212
+ export declare function isSimilar(left: string | SQL | SQL.Aliased | AnyColumn, right: string | SQL | SQL.Aliased | AnyColumn): SQL<boolean>;
203
213
  /**
204
214
  * Creates a PostgreSQL `<->` operator call (from pg_trgm extension) for similarity distance.
205
215
  * Returns the "distance" between the arguments, that is one minus the similarity() value.
@@ -208,4 +218,4 @@ export declare function isSimilar(left: string | SQL | AnyColumn, right: string
208
218
  * @param right The text value or expression to compare against.
209
219
  * @returns A Drizzle SQL object representing the similarity distance.
210
220
  */
211
- export declare function similarityDistance(left: string | SQL | AnyColumn, right: string | SQL | AnyColumn): SQL<number>;
221
+ export declare function similarityDistance(left: string | SQL | SQL.Aliased | AnyColumn, right: string | SQL | SQL.Aliased | AnyColumn): SQL<number>;
package/orm/sqls.js CHANGED
@@ -95,11 +95,17 @@ export function greatest(...values) {
95
95
  const sqlValues = values.map((value) => isNumber(value) ? sql.raw(String(value)) : value);
96
96
  return sql `greatest(${sql.join(sqlValues, sql.raw(', '))})`;
97
97
  }
98
+ export function unnest(array) {
99
+ return sql `unnest(${array})`;
100
+ }
98
101
  function getLanguageSql(language) {
99
102
  return isString(language) ? sql `'${sql.raw(language)}'` : sql `${language}`;
100
103
  }
101
- export function toTsVector(language, column) {
102
- return sql `to_tsvector(${getLanguageSql(language)}, ${column})`;
104
+ export function toTsVector(language, text) {
105
+ return sql `to_tsvector(${getLanguageSql(language)}, ${text})`;
106
+ }
107
+ export function tsvectorToArray(tsvector) {
108
+ return sql `tsvector_to_array(${tsvector})`;
103
109
  }
104
110
  /**
105
111
  * Creates a PostgreSQL `to_tsquery` function call.
@@ -205,6 +211,18 @@ export function similarity(left, right) {
205
211
  const rightSql = isString(right) ? sql `${right}` : right;
206
212
  return sql `similarity(${leftSql}, ${rightSql})`;
207
213
  }
214
+ /**
215
+ * Creates a PostgreSQL `word_similarity` function call (from pg_trgm extension).
216
+ * Calculates the word similarity between two strings based on trigram matching.
217
+ * @param left The first text column or expression.
218
+ * @param right The second text value or expression to compare against.
219
+ * @returns A Drizzle SQL object representing the similarity score (0 to 1).
220
+ */
221
+ export function wordSimilarity(left, right) {
222
+ const leftSql = isString(left) ? sql `${left}` : left;
223
+ const rightSql = isString(right) ? sql `${right}` : right;
224
+ return sql `word_similarity(${leftSql}, ${rightSql})`;
225
+ }
208
226
  /**
209
227
  * Creates a PostgreSQL `%` operator call (from pg_trgm extension) for similarity check.
210
228
  * Returns true if the similarity between the two arguments is greater than the current similarity threshold.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.10",
3
+ "version": "0.93.12",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -162,7 +162,7 @@
162
162
  }
163
163
  },
164
164
  "devDependencies": {
165
- "@stylistic/eslint-plugin": "5.4",
165
+ "@stylistic/eslint-plugin": "5.5",
166
166
  "@types/koa__router": "12.0",
167
167
  "@types/luxon": "3.7",
168
168
  "@types/mjml": "4.7",
@@ -171,7 +171,7 @@
171
171
  "@types/pg": "8.15",
172
172
  "concurrently": "9.2",
173
173
  "drizzle-kit": "0.31",
174
- "eslint": "9.37",
174
+ "eslint": "9.38",
175
175
  "globals": "16.4",
176
176
  "tsc-alias": "1.8",
177
177
  "typedoc-github-wiki-theme": "2.1",
@@ -7,7 +7,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
- import { EntityWithoutMetadata, GinIndex, Table } from '../orm/index.js';
10
+ import { EntityWithoutMetadata, GinIndex, Index, Table } from '../orm/index.js';
11
11
  import { StringProperty } from '../schema/index.js';
12
12
  let Test = class Test extends EntityWithoutMetadata {
13
13
  title;
package/test1.js CHANGED
@@ -41,10 +41,13 @@ async function bootstrap() {
41
41
  }
42
42
  async function main(_cancellationSignal) {
43
43
  const repository = injectRepository(Test);
44
- // await repository.insertMany(testData);
45
- const result = await repository.search({
44
+ if (await repository.count() == 0) {
45
+ await repository.insertMany(testData);
46
+ }
47
+ const result = await repository.trigramSearch({
46
48
  fields: ['title', 'content', 'tags'],
47
- query: 'smoothie',
49
+ text: 'Optimizing PostgreSQL Full-Text Search with GIN Indexes A deep dive into GIN index performance.',
50
+ trigram: { type: 'phrase', threshold: 0.1 },
48
51
  });
49
52
  console.log(result);
50
53
  }
package/test2.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/test2.js ADDED
@@ -0,0 +1,32 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { inject, Singleton } from './injector/index.js';
8
+ import { Application } from './application/application.js';
9
+ import { provideModule } from './application/providers.js';
10
+ import { PrettyPrintLogFormatter, provideConsoleLogTransport } from './logger/index.js';
11
+ let Foo = class Foo {
12
+ bar = inject(Bar);
13
+ };
14
+ Foo = __decorate([
15
+ Singleton()
16
+ ], Foo);
17
+ let Bar = class Bar {
18
+ };
19
+ Bar = __decorate([
20
+ Singleton({
21
+ argumentIdentityProvider() {
22
+ throw new Error('haha');
23
+ }
24
+ })
25
+ ], Bar);
26
+ function main() {
27
+ const foo = inject(Foo);
28
+ }
29
+ Application.run('Test', [
30
+ provideModule(main),
31
+ provideConsoleLogTransport(PrettyPrintLogFormatter)
32
+ ]);