@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.6

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.
Files changed (150) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +22 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +43 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/alternateBaseViews.d.ts +119 -0
  8. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  9. package/dist/generic/alternateBaseViews.js +223 -0
  10. package/dist/generic/alternateBaseViews.js.map +1 -0
  11. package/dist/generic/baseEngine.d.ts +82 -14
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +311 -46
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  16. package/dist/generic/baseEngineRegistry.js +2 -1
  17. package/dist/generic/baseEngineRegistry.js.map +1 -1
  18. package/dist/generic/baseEntity.d.ts +194 -14
  19. package/dist/generic/baseEntity.d.ts.map +1 -1
  20. package/dist/generic/baseEntity.js +638 -218
  21. package/dist/generic/baseEntity.js.map +1 -1
  22. package/dist/generic/baseInfo.d.ts +31 -3
  23. package/dist/generic/baseInfo.d.ts.map +1 -1
  24. package/dist/generic/baseInfo.js +138 -40
  25. package/dist/generic/baseInfo.js.map +1 -1
  26. package/dist/generic/contextTokens.d.ts +136 -0
  27. package/dist/generic/contextTokens.d.ts.map +1 -0
  28. package/dist/generic/contextTokens.js +319 -0
  29. package/dist/generic/contextTokens.js.map +1 -0
  30. package/dist/generic/databaseProviderBase.d.ts +103 -13
  31. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  32. package/dist/generic/databaseProviderBase.js +321 -77
  33. package/dist/generic/databaseProviderBase.js.map +1 -1
  34. package/dist/generic/embeddedRecord.d.ts +6 -0
  35. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  36. package/dist/generic/embeddedRecord.js +15 -1
  37. package/dist/generic/embeddedRecord.js.map +1 -1
  38. package/dist/generic/entityCompanion.d.ts +7 -0
  39. package/dist/generic/entityCompanion.d.ts.map +1 -1
  40. package/dist/generic/entityCompanion.js +8 -0
  41. package/dist/generic/entityCompanion.js.map +1 -1
  42. package/dist/generic/entityConfiguration.d.ts +8 -2
  43. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  44. package/dist/generic/entityConfiguration.js +9 -0
  45. package/dist/generic/entityConfiguration.js.map +1 -1
  46. package/dist/generic/entityInfo.d.ts +306 -12
  47. package/dist/generic/entityInfo.d.ts.map +1 -1
  48. package/dist/generic/entityInfo.js +894 -92
  49. package/dist/generic/entityInfo.js.map +1 -1
  50. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  51. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  52. package/dist/generic/entityValueFieldSecurity.js +250 -0
  53. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  54. package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
  55. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  56. package/dist/generic/fieldSecuritySQLGate.js +898 -0
  57. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  58. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  59. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  60. package/dist/generic/indexRestrictedFields.js +129 -0
  61. package/dist/generic/indexRestrictedFields.js.map +1 -0
  62. package/dist/generic/infoProviderResolution.d.ts +16 -0
  63. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  64. package/dist/generic/infoProviderResolution.js +17 -0
  65. package/dist/generic/infoProviderResolution.js.map +1 -0
  66. package/dist/generic/interfaces.d.ts +39 -2
  67. package/dist/generic/interfaces.d.ts.map +1 -1
  68. package/dist/generic/interfaces.js.map +1 -1
  69. package/dist/generic/localCacheManager.d.ts +318 -51
  70. package/dist/generic/localCacheManager.d.ts.map +1 -1
  71. package/dist/generic/localCacheManager.js +675 -109
  72. package/dist/generic/localCacheManager.js.map +1 -1
  73. package/dist/generic/logging.d.ts.map +1 -1
  74. package/dist/generic/logging.js +7 -1
  75. package/dist/generic/logging.js.map +1 -1
  76. package/dist/generic/metadata.d.ts +3 -2
  77. package/dist/generic/metadata.d.ts.map +1 -1
  78. package/dist/generic/metadata.js +5 -2
  79. package/dist/generic/metadata.js.map +1 -1
  80. package/dist/generic/mjapiUrl.d.ts +21 -0
  81. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  82. package/dist/generic/mjapiUrl.js +25 -0
  83. package/dist/generic/mjapiUrl.js.map +1 -0
  84. package/dist/generic/platformSQL.d.ts +54 -0
  85. package/dist/generic/platformSQL.d.ts.map +1 -1
  86. package/dist/generic/platformSQL.js +55 -0
  87. package/dist/generic/platformSQL.js.map +1 -1
  88. package/dist/generic/providerBase.d.ts +370 -57
  89. package/dist/generic/providerBase.d.ts.map +1 -1
  90. package/dist/generic/providerBase.js +1085 -284
  91. package/dist/generic/providerBase.js.map +1 -1
  92. package/dist/generic/queryInfo.d.ts +23 -9
  93. package/dist/generic/queryInfo.d.ts.map +1 -1
  94. package/dist/generic/queryInfo.js +73 -33
  95. package/dist/generic/queryInfo.js.map +1 -1
  96. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  97. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  98. package/dist/generic/queryTemplateContext.d.ts +49 -0
  99. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  100. package/dist/generic/queryTemplateContext.js +113 -0
  101. package/dist/generic/queryTemplateContext.js.map +1 -0
  102. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  103. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  104. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  105. package/dist/generic/relatedRecordCollection.d.ts +13 -0
  106. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  107. package/dist/generic/relatedRecordCollection.js +44 -5
  108. package/dist/generic/relatedRecordCollection.js.map +1 -1
  109. package/dist/generic/runQuery.d.ts +42 -0
  110. package/dist/generic/runQuery.d.ts.map +1 -1
  111. package/dist/generic/runQuery.js +14 -0
  112. package/dist/generic/runQuery.js.map +1 -1
  113. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  114. package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
  115. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  116. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.js +3 -0
  118. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  119. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  120. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  121. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  122. package/dist/generic/securityInfo.d.ts +17 -6
  123. package/dist/generic/securityInfo.d.ts.map +1 -1
  124. package/dist/generic/securityInfo.js +25 -10
  125. package/dist/generic/securityInfo.js.map +1 -1
  126. package/dist/generic/telemetryManager.d.ts +9 -0
  127. package/dist/generic/telemetryManager.d.ts.map +1 -1
  128. package/dist/generic/telemetryManager.js +5 -1
  129. package/dist/generic/telemetryManager.js.map +1 -1
  130. package/dist/generic/util.d.ts.map +1 -1
  131. package/dist/generic/util.js +3 -0
  132. package/dist/generic/util.js.map +1 -1
  133. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  134. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  135. package/dist/generic/wellKnownUserSource.js +18 -9
  136. package/dist/generic/wellKnownUserSource.js.map +1 -1
  137. package/dist/index.d.ts +8 -0
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +8 -0
  140. package/dist/index.js.map +1 -1
  141. package/dist/views/runView.d.ts +89 -0
  142. package/dist/views/runView.d.ts.map +1 -1
  143. package/dist/views/runView.js +36 -0
  144. package/dist/views/runView.js.map +1 -1
  145. package/dist/views/viewProperties.d.ts +55 -0
  146. package/dist/views/viewProperties.d.ts.map +1 -0
  147. package/dist/views/viewProperties.js +258 -0
  148. package/dist/views/viewProperties.js.map +1 -0
  149. package/package.json +3 -3
  150. package/readme.md +29 -4
@@ -1,4 +1,5 @@
1
1
  import { ProviderBase } from "./providerBase.js";
2
+ import { UserInfo } from "./securityInfo.js";
2
3
  import { EntityFieldTSType, EntityPermissionType } from "./entityInfo.js";
3
4
  import { BaseEntityResult, SerializeCloneChangeContext } from "./baseEntity.js";
4
5
  import { EntitySaveOptions, EntityDeleteOptions } from "./interfaces.js";
@@ -7,7 +8,8 @@ import { TransactionItem } from "./transactionGroup.js";
7
8
  import { CompositeKey } from "./compositeKey.js";
8
9
  import { LogError } from "./logging.js";
9
10
  import { LocalCacheManager } from "./localCacheManager.js";
10
- import { EscapeSQLString, FormatBinaryChangeValue, SQLExpressionValidator, StripSQLStringLiterals, uuidv4 } from "@memberjunction/global";
11
+ import { AssertClientInputWritableForUser } from "./entityValueFieldSecurity.js";
12
+ import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4 } from "@memberjunction/global";
11
13
  import { GetDialect } from "@memberjunction/sql-dialect";
12
14
  // Re-export PlatformSQL types from their canonical location for backward compatibility
13
15
  export { IsPlatformSQL } from "./platformSQL.js";
@@ -710,6 +712,110 @@ export class DatabaseProviderBase extends ProviderBase {
710
712
  /**************************************************************************/
711
713
  // START ---- Record Dependencies
712
714
  /**************************************************************************/
715
+ /**
716
+ * High-volume system audit/telemetry entities excluded when `options.IncludeSystemEntities === false`.
717
+ */
718
+ static { this._systemDependencyEntityNames = new Set([
719
+ 'mj: record changes',
720
+ 'mj: audit logs',
721
+ 'mj: user record logs',
722
+ 'mj: user recordaccess',
723
+ 'mj: entity record documents',
724
+ ]); }
725
+ /**
726
+ * Normalizes `(contextUserOrOptions, options)` so callers can invoke `GetRecordDependencies`
727
+ * with either `(entityName, key, options)` or `(entityName, key, contextUser, options)`.
728
+ */
729
+ resolveRecordDependencyArgs(contextUserOrOptions, options) {
730
+ if (options !== undefined) {
731
+ return { contextUser: contextUserOrOptions, options };
732
+ }
733
+ if (!contextUserOrOptions) {
734
+ return { contextUser: undefined, options: undefined };
735
+ }
736
+ if (contextUserOrOptions instanceof UserInfo ||
737
+ 'UserRoles' in contextUserOrOptions ||
738
+ 'Email' in contextUserOrOptions) {
739
+ return { contextUser: contextUserOrOptions, options: undefined };
740
+ }
741
+ return { contextUser: undefined, options: contextUserOrOptions };
742
+ }
743
+ /**
744
+ * Determines whether `relatedEntity` should be included in a record dependency scan
745
+ * based on `options` (`IncludeSystemEntities`, `EntityNames`) and `contextUser` read permissions.
746
+ */
747
+ shouldIncludeDependencyEntity(relatedEntity, options, contextUser) {
748
+ if (!this.shouldIncludeDependencyEntityName(relatedEntity.Name, options)) {
749
+ return false;
750
+ }
751
+ if (contextUser && typeof relatedEntity.CheckPermissions === 'function') {
752
+ if (!relatedEntity.CheckPermissions(contextUser, EntityPermissionType.Read, false)) {
753
+ return false;
754
+ }
755
+ }
756
+ return true;
757
+ }
758
+ /**
759
+ * Checks name-based dependency filters (`IncludeSystemEntities` and `EntityNames`)
760
+ * when only the entity name is available.
761
+ */
762
+ shouldIncludeDependencyEntityName(relatedEntityName, options) {
763
+ const normalizedName = relatedEntityName.trim().toLowerCase();
764
+ if (options?.IncludeSystemEntities === false) {
765
+ if (DatabaseProviderBase._systemDependencyEntityNames.has(normalizedName)) {
766
+ return false;
767
+ }
768
+ }
769
+ if (options?.EntityNames && options.EntityNames.length > 0) {
770
+ const allowed = options.EntityNames.some(n => n.trim().toLowerCase() === normalizedName);
771
+ if (!allowed) {
772
+ return false;
773
+ }
774
+ }
775
+ return true;
776
+ }
777
+ /**
778
+ * Resolves an `EntityDependency[] | string` argument to an `EntityDependency[]` array.
779
+ * Supports passing either a pre-resolved `EntityDependency[]` list or an `entityName` string.
780
+ */
781
+ resolveEntityDependencies(entityDependenciesOrName) {
782
+ if (Array.isArray(entityDependenciesOrName)) {
783
+ return entityDependenciesOrName;
784
+ }
785
+ const eName = entityDependenciesOrName.trim().toLowerCase();
786
+ const result = [];
787
+ for (const re of this.Entities) {
788
+ const relatedFields = re.Fields.filter(f => f.RelatedEntity?.trim().toLowerCase() === eName && (!f.EntityIDFieldName || f.EntityIDFieldName.trim().length === 0));
789
+ for (const f of relatedFields) {
790
+ result.push({
791
+ EntityName: entityDependenciesOrName,
792
+ RelatedEntityName: re.Name,
793
+ FieldName: f.Name,
794
+ });
795
+ }
796
+ }
797
+ return result;
798
+ }
799
+ /**
800
+ * Selects `relatedEntity.BaseTable` when `BaseTable` is defined, the entity is not virtual,
801
+ * and all referenced columns (`PrimaryKeys` plus `referencedFieldNames`) are physical (`!f.IsVirtual`).
802
+ * Falls back to `relatedEntity.BaseView` when any referenced column is virtual or `BaseTable` is absent.
803
+ */
804
+ resolveDependencySourceObject(relatedEntity, referencedFieldNames) {
805
+ if (!relatedEntity.BaseTable || relatedEntity.BaseTable.trim().length === 0 || relatedEntity.VirtualEntity) {
806
+ return relatedEntity.BaseView;
807
+ }
808
+ if (relatedEntity.PrimaryKeys?.some(pk => pk.IsVirtual === true)) {
809
+ return relatedEntity.BaseView;
810
+ }
811
+ for (const fieldName of referencedFieldNames) {
812
+ const field = relatedEntity.Fields?.find(f => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
813
+ if (field?.IsVirtual === true) {
814
+ return relatedEntity.BaseView;
815
+ }
816
+ }
817
+ return relatedEntity.BaseTable;
818
+ }
713
819
  /**
714
820
  * Returns a list of record-level dependencies — records in other entities linked to
715
821
  * the specified entity/record via foreign keys (hard links) or EntityIDFieldName
@@ -717,10 +823,12 @@ export class DatabaseProviderBase extends ProviderBase {
717
823
  *
718
824
  * @param entityName The entity name to check
719
825
  * @param compositeKey The primary key(s) of the record
720
- * @param contextUser Optional context user
826
+ * @param contextUser Optional context user (or options when called with 3 args)
827
+ * @param options Optional filtering options (`LinkType`, `IncludeSystemEntities`, `EntityNames`)
721
828
  */
722
- async GetRecordDependencies(entityName, compositeKey, contextUser) {
829
+ async GetRecordDependencies(entityName, compositeKey, contextUser, options) {
723
830
  try {
831
+ const { contextUser: resolvedUser, options: resolvedOptions } = this.resolveRecordDependencyArgs(contextUser, options);
724
832
  const recordDependencies = [];
725
833
  // Validate up front. Reporting "no dependencies" for an entity we cannot resolve would be
726
834
  // indistinguishable, to a caller about to delete a record, from "this record is safe to
@@ -728,19 +836,40 @@ export class DatabaseProviderBase extends ProviderBase {
728
836
  if (!this.EntityByName(entityName)) {
729
837
  throw new Error(`Entity ${entityName} not found in metadata`);
730
838
  }
731
- const entityDependencies = await this.GetEntityDependencies(entityName);
732
- // Deliberately NO early return when there are no hard (foreign key) dependents. An entity
733
- // whose only dependents are polymorphic EntityID/RecordID links is precisely the case the
734
- // soft-link query exists to serve, and returning here skipped building it entirely.
735
- const hardSQL = entityDependencies.length > 0 ? this.BuildHardLinkDependencySQL(entityDependencies, compositeKey) : '';
736
- const softSQL = this.BuildSoftLinkDependencySQL(entityName, compositeKey);
839
+ const linkType = resolvedOptions?.LinkType ?? 'both';
840
+ let hardSQL = '';
841
+ if (linkType === 'both' || linkType === 'hard') {
842
+ const rawEntityDependencies = await this.GetEntityDependencies(entityName);
843
+ const entityDependencies = rawEntityDependencies.filter((dep) => {
844
+ const relEntity = this.EntityByName(dep.RelatedEntityName);
845
+ if (relEntity) {
846
+ return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
847
+ }
848
+ return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
849
+ });
850
+ // Deliberately NO early return when there are no hard (foreign key) dependents. An entity
851
+ // whose only dependents are polymorphic EntityID/RecordID links is precisely the case the
852
+ // soft-link query exists to serve, and returning here skipped building it entirely.
853
+ hardSQL = entityDependencies.length > 0
854
+ ? this.BuildHardLinkDependencySQL(entityDependencies, compositeKey, resolvedOptions, resolvedUser)
855
+ : '';
856
+ }
857
+ const softSQL = linkType === 'both' || linkType === 'soft'
858
+ ? this.BuildSoftLinkDependencySQL(entityName, compositeKey, resolvedOptions, resolvedUser)
859
+ : '';
737
860
  const sSQL = [hardSQL, softSQL].filter(s => s.length > 0).join(' UNION ALL ');
738
861
  if (!sSQL)
739
862
  return recordDependencies;
740
- const result = await this.ExecuteSQL(sSQL, undefined, undefined, contextUser);
863
+ const result = await this.ExecuteSQL(sSQL, undefined, undefined, resolvedUser);
741
864
  if (!result || result.length === 0)
742
865
  return recordDependencies;
743
- return this.parseRecordDependencyResults(result);
866
+ return this.parseRecordDependencyResults(result).filter((dep) => {
867
+ const relEntity = this.EntityByName(dep.RelatedEntityName);
868
+ if (relEntity) {
869
+ return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
870
+ }
871
+ return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
872
+ });
744
873
  }
745
874
  catch (e) {
746
875
  LogError(e);
@@ -787,10 +916,16 @@ export class DatabaseProviderBase extends ProviderBase {
787
916
  pkeys[parts[0]] = parts.slice(1).join(CompositeKey.DefaultValueDelimiter);
788
917
  });
789
918
  depCompositeKey.LoadFromEntityInfoAndRecord(relatedEntityInfo, pkeys);
919
+ const fieldName = r.FieldName;
920
+ const fieldInfo = relatedEntityInfo.Fields?.find((f) => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
921
+ const fieldDisplayName = fieldInfo
922
+ ? (fieldInfo.DisplayNameOrName || fieldInfo.DisplayName || fieldInfo.Name)
923
+ : undefined;
790
924
  recordDependencies.push({
791
925
  EntityName: r.EntityName,
792
926
  RelatedEntityName: r.RelatedEntityName,
793
- FieldName: r.FieldName,
927
+ FieldName: fieldName,
928
+ FieldDisplayName: fieldDisplayName,
794
929
  PrimaryKey: depCompositeKey,
795
930
  // Both dialects emit this literal on every row of the union: 1/true for the polymorphic
796
931
  // branch, 0/false for the foreign key branch.
@@ -933,8 +1068,23 @@ export class DatabaseProviderBase extends ProviderBase {
933
1068
  // Always offer the record-change-free form to the SQL logger, not only when the
934
1069
  // entity tracks record changes: it is also the replay-safe form of a create for
935
1070
  // migration recordings (MemberJunction/MJ#4503). Execution still uses fullSQL.
936
- if (sqlDetails.simpleSQL) {
937
- opts.simpleSQLFallback = sqlDetails.simpleSQL;
1071
+ if ('simpleSQL' in sqlDetails) {
1072
+ Object.defineProperty(opts, 'simpleSQLFallback', {
1073
+ enumerable: true,
1074
+ configurable: true,
1075
+ get() {
1076
+ return sqlDetails.simpleSQL || undefined;
1077
+ },
1078
+ });
1079
+ }
1080
+ if ('literalSQL' in sqlDetails) {
1081
+ Object.defineProperty(opts, 'literalSQL', {
1082
+ enumerable: true,
1083
+ configurable: true,
1084
+ get() {
1085
+ return sqlDetails.literalSQL || undefined;
1086
+ },
1087
+ });
938
1088
  }
939
1089
  return opts;
940
1090
  }
@@ -946,8 +1096,23 @@ export class DatabaseProviderBase extends ProviderBase {
946
1096
  isMutation: true,
947
1097
  description: `Delete ${entity.EntityInfo.Name}`,
948
1098
  };
949
- if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
950
- opts.simpleSQLFallback = sqlDetails.simpleSQL;
1099
+ if (entity.EntityInfo.TrackRecordChanges && 'simpleSQL' in sqlDetails) {
1100
+ Object.defineProperty(opts, 'simpleSQLFallback', {
1101
+ enumerable: true,
1102
+ configurable: true,
1103
+ get() {
1104
+ return sqlDetails.simpleSQL || undefined;
1105
+ },
1106
+ });
1107
+ }
1108
+ if ('literalSQL' in sqlDetails) {
1109
+ Object.defineProperty(opts, 'literalSQL', {
1110
+ enumerable: true,
1111
+ configurable: true,
1112
+ get() {
1113
+ return sqlDetails.literalSQL || undefined;
1114
+ },
1115
+ });
951
1116
  }
952
1117
  return opts;
953
1118
  }
@@ -976,26 +1141,27 @@ export class DatabaseProviderBase extends ProviderBase {
976
1141
  /**************************************************************************/
977
1142
  /**
978
1143
  * Validates a user-provided SQL clause (WHERE, ORDER BY, etc.) to prevent SQL injection.
979
- * Checks for forbidden keywords (INSERT, UPDATE, DELETE, EXEC, DROP, UNION, etc.)
980
- * and dangerous patterns (comments, semicolons, xp_ prefix).
981
- * String literals are stripped before validation to avoid false positives.
1144
+ * The clause is read with this provider's SQL dialect. It is rejected when it contains a
1145
+ * comment, text that cannot be read the way the database reads it, a forbidden keyword
1146
+ * (INSERT, UPDATE, DELETE, EXEC, DROP, UNION, etc.), a semicolon, or an `xp_` name.
1147
+ * String literals and quoted identifiers are not checked for keywords.
982
1148
  *
983
1149
  * @param clause The SQL clause to validate
984
1150
  * @returns true if the clause is safe, false if it contains forbidden patterns
985
1151
  */
986
1152
  ValidateUserProvidedSQLClause(clause) {
987
- // Remove string literals to avoid false positives.
988
- //
989
- // 🚨 SECURITY: this uses the SHARED stripper in @memberjunction/global. It must match how
990
- // SQL Server / PostgreSQL actually parse literals — see StripSQLStringLiterals for the full
991
- // rationale and the backslash-escape bypass it exists to prevent. Do NOT inline a regex here;
992
- // an inline copy is exactly how this screen and SQLExpressionValidator drifted apart before.
993
- const clauseWithoutStrings = StripSQLStringLiterals(clause);
994
- const lowerClause = clauseWithoutStrings.toLowerCase();
1153
+ // 🚨 SECURITY: this uses the SHARED scanner in @memberjunction/global, which reads quotes,
1154
+ // [bracket] and "double-quoted" identifiers, comments, and PostgreSQL E'…' and $$…$$ strings
1155
+ // the way this provider's database does, and refuses text it cannot classify. Do NOT inline
1156
+ // a regex here: this screen and SQLExpressionValidator must read SQL the same way.
1157
+ const scan = ScanSQLForScreening(clause, this.PlatformKey);
1158
+ if (!scan.Classified || scan.CommentCount > 0)
1159
+ return false;
1160
+ const lowerClause = scan.Code.toLowerCase();
995
1161
  const forbiddenPatterns = [
996
1162
  /\binsert\b/, /\bupdate\b/, /\bdelete\b/,
997
1163
  /\bexec\b/, /\bexecute\b/, /\bdrop\b/,
998
- /--/, /\/\*/, /\*\//, /\bunion\b/, /\bxp_/, /;/,
1164
+ /--/, /\/\*/, /\*\//, /\bunion\b/, /;/,
999
1165
  // Time-based blind injection vector — no legitimate filter/order-by clause uses WAITFOR.
1000
1166
  /\bwaitfor\b/,
1001
1167
  ];
@@ -1003,7 +1169,8 @@ export class DatabaseProviderBase extends ProviderBase {
1003
1169
  if (pattern.test(lowerClause))
1004
1170
  return false;
1005
1171
  }
1006
- return true;
1172
+ // Extended stored procedures are names, so a quoted name such as [xp_cmdshell] is checked too.
1173
+ return !/\bxp_/.test(scan.CodeWithIdentifiers.toLowerCase());
1007
1174
  }
1008
1175
  /**
1009
1176
  * Checks that the given user has read permissions on the specified entity.
@@ -1052,6 +1219,7 @@ export class DatabaseProviderBase extends ProviderBase {
1052
1219
  const result = validator.validate(agg.expression, {
1053
1220
  context: 'aggregate',
1054
1221
  entityFields: fieldNames,
1222
+ Dialect: this.PlatformKey,
1055
1223
  });
1056
1224
  if (!result.valid) {
1057
1225
  validationErrors.push({
@@ -1138,14 +1306,20 @@ export class DatabaseProviderBase extends ProviderBase {
1138
1306
  }
1139
1307
  }
1140
1308
  /**
1141
- * Builds the SQL to retrieve the "name" field value for a specific entity record.
1142
- * Uses QuoteIdentifier/QuoteSchemaAndView for dialect-neutral SQL generation.
1309
+ * Builds the query that reads a record's name fields by its primary key.
1310
+ *
1311
+ * Key columns are written from the entity's metadata and key values are bound as parameters,
1312
+ * so nothing the caller supplies reaches the SQL text. When a user is given, their effective
1313
+ * read row filter (role RLS and API-key row filters) is ANDed in. A caller that gives no user
1314
+ * is trusted server code, as for a single-record Load.
1143
1315
  *
1144
1316
  * @param entityName The entity name
1145
1317
  * @param compositeKey The record's primary key
1146
- * @returns The SQL query string, or null if the entity has no name field
1318
+ * @param contextUser The acting user, whose read row filter applies
1319
+ * @returns The SQL and its parameter values, or null if the entity has no name field or the
1320
+ * key does not name the entity's primary key fields
1147
1321
  */
1148
- BuildEntityRecordNameSQL(entityName, compositeKey) {
1322
+ BuildEntityRecordNameSQL(entityName, compositeKey, contextUser) {
1149
1323
  const e = this.EntityByName(entityName);
1150
1324
  if (!e)
1151
1325
  throw new Error('Entity ' + entityName + ' not found');
@@ -1154,30 +1328,44 @@ export class DatabaseProviderBase extends ProviderBase {
1154
1328
  LogError('Entity ' + entityName + ' does not have a NameField, returning null');
1155
1329
  return null;
1156
1330
  }
1157
- let where = '';
1158
- for (const pkv of compositeKey.KeyValuePairs) {
1159
- const pk = e.PrimaryKeys.find((pk) => pk.Name === pkv.FieldName);
1160
- if (where.length > 0)
1161
- where += ' AND ';
1162
- if (pk && pk.NeedsQuotes) {
1163
- // Key values arrive from remote callers — escape so a quote in the value cannot
1164
- // break out of the literal (same discipline as CompositeKey.ToWhereClause).
1165
- where += this.QuoteIdentifier(pkv.FieldName) + "='" + EscapeSQLString(String(pkv.Value)) + "'";
1166
- }
1167
- else {
1168
- // Unquoted (numeric) key column: the value is spliced in bare, so refuse anything
1169
- // that is not a plain number rather than letting it reach the SQL text.
1170
- const raw = String(pkv.Value);
1171
- if (!/^-?\d+(\.\d+)?$/.test(raw)) {
1172
- LogError(`BuildEntityRecordNameSQL: non-numeric value provided for numeric key field ${pkv.FieldName} on entity ${entityName}`);
1173
- return null;
1174
- }
1175
- where += this.QuoteIdentifier(pkv.FieldName) + '=' + raw;
1176
- }
1331
+ const keyFields = this.RecordNameKeyFieldsOf(e, compositeKey);
1332
+ if (!keyFields) {
1333
+ return null;
1334
+ }
1335
+ const conditions = keyFields.map((f, i) => this.QuoteIdentifier(f.Name) + '=' + this.BuildParameterPlaceholder(i));
1336
+ const rowFilter = contextUser ? e.GetEffectiveRowFilterWhereClause(contextUser, EntityPermissionType.Read, '') : '';
1337
+ if (rowFilter) {
1338
+ conditions.push('(' + rowFilter + ')');
1177
1339
  }
1178
1340
  // SELECT all name fields so InternalGetEntityRecordName can concatenate them
1179
1341
  const selectFields = nameFields.map(f => this.QuoteIdentifier(f.Name)).join(', ');
1180
- return 'SELECT ' + selectFields + ' FROM ' + this.QuoteSchemaAndView(e.SchemaName, e.BaseView) + ' WHERE ' + where;
1342
+ return {
1343
+ SQL: 'SELECT ' + selectFields + ' FROM ' + this.QuoteSchemaAndView(e.SchemaName, e.BaseView) + ' WHERE ' + conditions.join(' AND '),
1344
+ Parameters: compositeKey.KeyValuePairs.map(kv => kv.Value),
1345
+ };
1346
+ }
1347
+ /**
1348
+ * The primary key field each pair of a record-name key names, in key order. Names match
1349
+ * case-insensitively. Null, after logging, when the key is empty or names a field that is not
1350
+ * one of the entity's primary keys.
1351
+ */
1352
+ RecordNameKeyFieldsOf(entity, compositeKey) {
1353
+ const pairs = compositeKey?.KeyValuePairs ?? [];
1354
+ if (pairs.length === 0) {
1355
+ LogError(`Record name lookup on ${entity.Name}: the key names no fields`);
1356
+ return null;
1357
+ }
1358
+ const keyFields = [];
1359
+ for (const pair of pairs) {
1360
+ const name = String(pair.FieldName ?? '').trim().toLowerCase();
1361
+ const field = entity.PrimaryKeys.find(pk => pk.Name.trim().toLowerCase() === name);
1362
+ if (!field) {
1363
+ LogError(`Record name lookup on ${entity.Name}: ${JSON.stringify(pair.FieldName)} is not a primary key field`);
1364
+ return null;
1365
+ }
1366
+ keyFields.push(field);
1367
+ }
1368
+ return keyFields;
1181
1369
  }
1182
1370
  /**
1183
1371
  * The fields a record's display name is built from, in order: every `IsNameField` field by
@@ -1199,7 +1387,7 @@ export class DatabaseProviderBase extends ProviderBase {
1199
1387
  * because there is nobody to check against.
1200
1388
  */
1201
1389
  CanUserReadRecordName(entity, contextUser) {
1202
- if (!entity.EnableFieldLevelSecurity) {
1390
+ if (!entity.FieldLevelSecurityApplies) {
1203
1391
  return true;
1204
1392
  }
1205
1393
  if (!contextUser) {
@@ -1210,7 +1398,8 @@ export class DatabaseProviderBase extends ProviderBase {
1210
1398
  }
1211
1399
  /**
1212
1400
  * Retrieves the display name for a single entity record.
1213
- * Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation.
1401
+ * Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation, which limits the lookup to
1402
+ * rows the acting user's read row filter allows.
1214
1403
  *
1215
1404
  * Answers with an empty string, without querying, when field-level security withholds any of
1216
1405
  * the name fields from the acting user — the same answer as a record that does not exist, so
@@ -1222,9 +1411,9 @@ export class DatabaseProviderBase extends ProviderBase {
1222
1411
  if (entity && !this.CanUserReadRecordName(entity, contextUser)) {
1223
1412
  return '';
1224
1413
  }
1225
- const sql = this.BuildEntityRecordNameSQL(entityName, compositeKey);
1226
- if (sql) {
1227
- const data = await this.ExecuteSQL(sql, undefined, undefined, contextUser);
1414
+ const query = this.BuildEntityRecordNameSQL(entityName, compositeKey, contextUser);
1415
+ if (query) {
1416
+ const data = await this.ExecuteSQL(query.SQL, query.Parameters, undefined, contextUser);
1228
1417
  if (data && data.length === 1) {
1229
1418
  // Concatenate all returned fields with spaces (supports multi-name entities)
1230
1419
  const values = Object.values(data[0])
@@ -1407,22 +1596,45 @@ export class DatabaseProviderBase extends ProviderBase {
1407
1596
  }
1408
1597
  // Step 4: Generate provider-specific SQL
1409
1598
  const sqlDetails = await this.GenerateSaveSQL(entity, bNewRecord, user, options);
1599
+ const effectiveReturnMode = options?.SaveReturnMode ?? entity.EntityInfo.SaveReturnMode;
1600
+ // Snapshot the entity's in-memory field values at the exact point SQL parameters were bound
1601
+ // (before awaiting ExecuteSQL). If the caller mutates the entity in memory while an async
1602
+ // save is in flight (e.g. BaseEntitySaveQueue pipelining an INSERT + UPDATE), merging
1603
+ // post-await entity.GetAll() would falsely mark those in-flight mutations as clean/saved.
1604
+ const preSaveValues = effectiveReturnMode === 'datestamps_only'
1605
+ ? entity.GetAll()
1606
+ : undefined;
1607
+ if (preSaveValues && bNewRecord) {
1608
+ // On a new record saved with 'datestamps_only', the SP does not query BaseView,
1609
+ // so virtual non-PK fields (e.g. joined name columns) that were never set stay
1610
+ // null. Omit them from preSaveValues so finalizeSave marks them NotLoaded=true.
1611
+ for (const f of entity.EntityInfo.Fields) {
1612
+ if (f.IsVirtual && !f.IsPrimaryKey && (preSaveValues[f.Name] === null || preSaveValues[f.Name] === undefined)) {
1613
+ delete preSaveValues[f.Name];
1614
+ }
1615
+ }
1616
+ }
1410
1617
  if (entity.TransactionGroup && !bReplay) {
1411
1618
  // ---- Transaction Group path ----
1412
1619
  entity.RaiseReadyForTransaction();
1413
1620
  suspendRefresh();
1414
1621
  const extraData = this.GetTransactionExtraData(entity);
1415
- if (sqlDetails.simpleSQL) {
1416
- extraData.simpleSQLFallback = sqlDetails.simpleSQL;
1417
- }
1622
+ Object.defineProperty(extraData, 'simpleSQLFallback', {
1623
+ get: () => sqlDetails.simpleSQL,
1624
+ enumerable: true,
1625
+ configurable: true,
1626
+ });
1418
1627
  extraData.entityName = entity.EntityInfo.Name;
1419
- entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
1628
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
1420
1629
  resumeRefresh();
1421
1630
  entityResult.EndedAt = new Date();
1422
1631
  if (success && transactionResult) {
1423
1632
  this.OnAfterSaveExecute(entity, user, options, saveContext);
1424
1633
  entityResult.Success = true;
1425
- entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
1634
+ const mergedResult = preSaveValues
1635
+ ? { ...preSaveValues, ...transactionResult }
1636
+ : transactionResult;
1637
+ entityResult.NewValues = this.MapTransactionResultToNewValues(mergedResult);
1426
1638
  }
1427
1639
  else {
1428
1640
  entityResult.Success = false;
@@ -1448,20 +1660,36 @@ export class DatabaseProviderBase extends ProviderBase {
1448
1660
  if (result && result.length > 0) {
1449
1661
  this.OnAfterSaveExecute(entity, user, options, saveContext);
1450
1662
  entityResult.Success = true;
1663
+ // In 'datestamps_only' mode, the SP/query returned only the PK and
1664
+ // __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
1665
+ // onto the pre-save snapshot so caller-set fields remain intact without
1666
+ // falsely marking concurrent in-flight mutations as clean.
1667
+ let mergedRow;
1668
+ if (preSaveValues) {
1669
+ mergedRow = { ...preSaveValues, ...result[0] };
1670
+ Object.defineProperty(mergedRow, '__mj_preMerged', {
1671
+ value: true,
1672
+ enumerable: false,
1673
+ configurable: true,
1674
+ });
1675
+ }
1676
+ else {
1677
+ mergedRow = result[0];
1678
+ }
1451
1679
  // OnSaveCompleted may return patch fields (e.g., updated lat/lng
1452
1680
  // from geocoding) that need to be merged onto the SP result before
1453
1681
  // the entity loads the data via finalizeSave().
1454
1682
  const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
1455
1683
  if (patches) {
1456
- Object.assign(result[0], patches);
1684
+ Object.assign(mergedRow, patches);
1457
1685
  }
1458
- return result[0];
1686
+ return mergedRow;
1459
1687
  }
1460
1688
  else {
1461
1689
  if (bNewRecord)
1462
- throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.fullSQL}`);
1690
+ throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1463
1691
  else
1464
- throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.fullSQL}`);
1692
+ throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
1465
1693
  }
1466
1694
  }
1467
1695
  }
@@ -1531,20 +1759,24 @@ export class DatabaseProviderBase extends ProviderBase {
1531
1759
  // ---- Transaction Group path ----
1532
1760
  entity.RaiseReadyForTransaction();
1533
1761
  const extraData = this.GetTransactionExtraData(entity);
1534
- if (entity.EntityInfo.TrackRecordChanges && sqlDetails.simpleSQL) {
1535
- extraData.simpleSQLFallback = sqlDetails.simpleSQL;
1762
+ if (entity.EntityInfo.TrackRecordChanges) {
1763
+ Object.defineProperty(extraData, 'simpleSQLFallback', {
1764
+ get: () => sqlDetails.simpleSQL,
1765
+ enumerable: true,
1766
+ configurable: true,
1767
+ });
1536
1768
  }
1537
1769
  extraData.entityName = entity.EntityInfo.Name;
1538
- entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
1770
+ entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
1539
1771
  entityResult.EndedAt = new Date();
1540
1772
  if (success && transactionResult) {
1541
- this.OnAfterDeleteExecute(entity, user, options);
1542
- for (const key of entity.PrimaryKeys) {
1543
- if (key.Value !== transactionResult[key.Name]) {
1544
- entityResult.Success = false;
1545
- entityResult.Message = 'Transaction failed to commit';
1546
- }
1773
+ // Same key check as the direct path, through the provider's own override
1774
+ // (PostgreSQL accepts its legacy `_result_id` shape there).
1775
+ if (!this.ValidateDeleteResult(entity, [transactionResult], entityResult)) {
1776
+ entityResult.Success = false;
1777
+ return;
1547
1778
  }
1779
+ this.OnAfterDeleteExecute(entity, user, options);
1548
1780
  entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
1549
1781
  entityResult.Success = true;
1550
1782
  }
@@ -1890,6 +2122,11 @@ export class DatabaseProviderBase extends ProviderBase {
1890
2122
  duplicateRun.Set('ProcessingStatus', 'In Progress');
1891
2123
  duplicateRun.Set('ApprovalStatus', 'Pending');
1892
2124
  duplicateRun.Set('SourceListID', listEntity.Get('ID'));
2125
+ // Without a document, the server-side Duplicate Run entity defaults to the entity's
2126
+ // duplicate-detection document.
2127
+ if (params.EntityDocumentID) {
2128
+ duplicateRun.Set('EntityDocumentID', params.EntityDocumentID);
2129
+ }
1893
2130
  const saveResult = await duplicateRun.Save();
1894
2131
  if (!saveResult) {
1895
2132
  throw new Error('Failed to save Duplicate Run Entity');
@@ -1926,6 +2163,13 @@ export class DatabaseProviderBase extends ProviderBase {
1926
2163
  throw new Error(`Record ${key.ToString()} of ${request.EntityName} has a ${child.ChildEntityName} subtype row; merging records another entity extends is not supported yet`);
1927
2164
  }
1928
2165
  }
2166
+ // The field map is client input applied to the surviving record: a caller with any field
2167
+ // denial may name only fields they can read, and a denied name and an unknown name get the
2168
+ // same refusal. Checked before the merge log and the transaction, so a refusal leaves no trace.
2169
+ if (request.FieldMap?.length) {
2170
+ const values = Object.fromEntries(request.FieldMap.map(f => [f.FieldName, f.Value]));
2171
+ AssertClientInputWritableForUser(e, values, contextUser);
2172
+ }
1929
2173
  const result = {
1930
2174
  Success: false,
1931
2175
  RecordMergeLogID: null,