@memberjunction/generic-database-provider 5.32.0 → 5.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/dist/GenericDatabaseProvider.d.ts +111 -0
- package/dist/GenericDatabaseProvider.d.ts.map +1 -1
- package/dist/GenericDatabaseProvider.js +334 -23
- package/dist/GenericDatabaseProvider.js.map +1 -1
- package/dist/SqlLogger.d.ts +12 -6
- package/dist/SqlLogger.d.ts.map +1 -1
- package/dist/SqlLogger.js +13 -7
- package/dist/SqlLogger.js.map +1 -1
- package/dist/crudSprocFieldRules.d.ts +85 -0
- package/dist/crudSprocFieldRules.d.ts.map +1 -0
- package/dist/crudSprocFieldRules.js +106 -0
- package/dist/crudSprocFieldRules.js.map +1 -0
- package/dist/dbPlatformEnv.d.ts +24 -0
- package/dist/dbPlatformEnv.d.ts.map +1 -0
- package/dist/dbPlatformEnv.js +35 -0
- package/dist/dbPlatformEnv.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @module @memberjunction/generic-database-provider
|
|
17
17
|
*/
|
|
18
|
-
import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, QueryInfo, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, QueryCacheManager, } from '@memberjunction/core';
|
|
18
|
+
import { DatabaseProviderBase, EntityFieldTSType, EntityPermissionType, InMemoryLocalStorageProvider, Metadata, RunView, QueryInfo, LocalCacheManager, LogError, LogStatus, LogStatusEx, StripStopWords, QueryCacheManager, AfterKeyNotSupportedError, IsKeysetPaginationOrderableType, } from '@memberjunction/core';
|
|
19
19
|
import { MJGlobal, SQLExpressionValidator, UUIDsEqual } from '@memberjunction/global';
|
|
20
20
|
import { QueryPagingEngine } from './queryPagingEngine.js';
|
|
21
21
|
// QueryParameterProcessor is now called internally by RenderPipeline
|
|
@@ -23,6 +23,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
23
23
|
import { SqlLoggingSessionImpl } from './SqlLogger.js';
|
|
24
24
|
// QueryCompositionEngine is now owned by RenderPipeline
|
|
25
25
|
import { RenderPipeline } from './renderPipeline.js';
|
|
26
|
+
import { useJsonArgShape } from './crudSprocFieldRules.js';
|
|
26
27
|
import { QueryEngine, ViewInfo, } from '@memberjunction/core-entities';
|
|
27
28
|
import { AIEngine } from '@memberjunction/aiengine';
|
|
28
29
|
import { QueueManager } from '@memberjunction/queue';
|
|
@@ -133,13 +134,18 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
133
134
|
async CreateSqlLogger(filePath, options) {
|
|
134
135
|
const sessionId = uuidv4();
|
|
135
136
|
const mjCoreSchema = this.ConfigData.MJCoreSchemaName;
|
|
137
|
+
const dialect = this.getDialect();
|
|
136
138
|
const session = new SqlLoggingSessionImpl(sessionId, filePath, {
|
|
137
139
|
defaultSchemaName: mjCoreSchema,
|
|
138
140
|
// Inject the platform's batch separator as the default so callers don't need to
|
|
139
141
|
// hardcode 'GO'. Callers can still override by passing batchSeparator explicitly.
|
|
140
142
|
batchSeparator: this.PlatformBatchSeparator || undefined,
|
|
141
143
|
...options
|
|
142
|
-
}
|
|
144
|
+
},
|
|
145
|
+
// Pass the platform dialect through so SQL emission (Flyway placeholder escaping etc.)
|
|
146
|
+
// uses the right form for SQL Server vs. PostgreSQL. Falls back to the constructor's
|
|
147
|
+
// default (SQLServerDialect) when a subclass hasn't overridden getDialect().
|
|
148
|
+
dialect ?? undefined);
|
|
143
149
|
// Initialize the session (create file, write header)
|
|
144
150
|
await session.initialize();
|
|
145
151
|
// Store in active sessions map
|
|
@@ -678,6 +684,38 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
678
684
|
get PlatformBatchSeparator() {
|
|
679
685
|
return this.getDialect()?.BatchSeparator() ?? '';
|
|
680
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* Maximum number of parameters a CRUD stored procedure (`spCreate*`/`spUpdate*`/`spDelete*`)
|
|
689
|
+
* may declare on this database platform before CodeGen must emit a JSON-arg shape instead.
|
|
690
|
+
*
|
|
691
|
+
* - SQL Server: `Infinity` — SS supports up to 2,100 parameters per procedure, far beyond
|
|
692
|
+
* any realistic CRUD sproc.
|
|
693
|
+
* - PostgreSQL: `90` — PG's hard `FUNC_MAX_ARGS` ceiling is 100 (compiled into the server,
|
|
694
|
+
* not configurable on managed services like RDS/Aurora). 90 leaves headroom for column
|
|
695
|
+
* adds without flipping sproc shape unexpectedly.
|
|
696
|
+
*
|
|
697
|
+
* Used by the shared `useJsonArgShape` predicate (CodeGen + provider call-site) to decide
|
|
698
|
+
* whether a given sproc should emit as typed-args + `_Clear` companions (today's shape) or
|
|
699
|
+
* as a single-JSONB-arg sproc with key-presence tri-state semantics. See
|
|
700
|
+
* [plans/json-arg-crud-sprocs.md](../../../plans/json-arg-crud-sprocs.md) and GitHub
|
|
701
|
+
* issue #2552.
|
|
702
|
+
*/
|
|
703
|
+
get ProcedureParamLimit() {
|
|
704
|
+
return Infinity;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Returns true when CRUD sproc generation and call-construction for the
|
|
708
|
+
* given entity + sproc verb should use a single JSON-arg shape (instead of
|
|
709
|
+
* typed args + `_Clear` companions).
|
|
710
|
+
*
|
|
711
|
+
* Convenience wrapper around the pure `useJsonArgShape` helper, applying
|
|
712
|
+
* this provider's `ProcedureParamLimit`. CodeGen and runtime call-sites
|
|
713
|
+
* can invoke this via the provider instance to keep sproc emit and sproc
|
|
714
|
+
* invocation in lockstep.
|
|
715
|
+
*/
|
|
716
|
+
UseJsonArgShape(entity, sprocType) {
|
|
717
|
+
return useJsonArgShape(entity, sprocType, this.ProcedureParamLimit);
|
|
718
|
+
}
|
|
681
719
|
/**
|
|
682
720
|
* Builds a platform-specific TOP/LIMIT clause for non-paginated row limits.
|
|
683
721
|
* SQL Server: `TOP N`; PostgreSQL returns empty (uses LIMIT via BuildPaginationSQL).
|
|
@@ -724,6 +762,138 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
724
762
|
return null;
|
|
725
763
|
return `SELECT COUNT(*) AS ${this.QuoteIdentifier('TotalRowCount')} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.BaseView)}`;
|
|
726
764
|
}
|
|
765
|
+
/**
|
|
766
|
+
* Validates that the entity and RunViewParams are compatible with keyset (AfterKey) pagination,
|
|
767
|
+
* then returns the SQL predicate (`<pk> > 'value'` or `<pk> < 'value'`) and the resolved
|
|
768
|
+
* order-by direction.
|
|
769
|
+
*
|
|
770
|
+
* See {@link AfterKeyNotSupportedError} for the validation rules and {@link RunViewParams.AfterKey}
|
|
771
|
+
* for the API contract.
|
|
772
|
+
*
|
|
773
|
+
* @throws AfterKeyNotSupportedError on validation failure
|
|
774
|
+
*/
|
|
775
|
+
BuildKeysetSeekClause(entityInfo, params) {
|
|
776
|
+
const afterKey = params.AfterKey;
|
|
777
|
+
// 1. Single-column PK requirement
|
|
778
|
+
const pkFields = entityInfo.PrimaryKeys;
|
|
779
|
+
if (!pkFields || pkFields.length === 0) {
|
|
780
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'CompositePK', `AfterKey requires a primary key on entity "${entityInfo.Name}", but none is defined.`);
|
|
781
|
+
}
|
|
782
|
+
if (pkFields.length > 1) {
|
|
783
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'CompositePK', `AfterKey requires a single-column primary key. Entity "${entityInfo.Name}" has a composite PK (${pkFields.length} columns: ${pkFields.map(f => f.Name).join(', ')}). Use StartRow-based pagination for composite-PK entities, or restructure the workload.`);
|
|
784
|
+
}
|
|
785
|
+
const pkField = pkFields[0];
|
|
786
|
+
// 2. Orderable PK type
|
|
787
|
+
if (!IsKeysetPaginationOrderableType(pkField.Type)) {
|
|
788
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'UnsupportedPKType', `AfterKey is not supported for entity "${entityInfo.Name}" because its PK column "${pkField.Name}" has type "${pkField.Type}", which is not in the keyset-orderable allowlist.`);
|
|
789
|
+
}
|
|
790
|
+
// 3. StartRow conflict
|
|
791
|
+
if (params.StartRow !== undefined && params.StartRow > 0) {
|
|
792
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'StartRowConflict', `AfterKey cannot be combined with StartRow > 0. Use one or the other.`);
|
|
793
|
+
}
|
|
794
|
+
// 4. AfterKey shape — must contain exactly one key matching the PK column
|
|
795
|
+
const pairs = afterKey.KeyValuePairs ?? [];
|
|
796
|
+
if (pairs.length !== 1) {
|
|
797
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'AfterKeyShape', `AfterKey must contain exactly one key/value pair matching the entity's PK column "${pkField.Name}". Got ${pairs.length} pairs.`);
|
|
798
|
+
}
|
|
799
|
+
const pair = pairs[0];
|
|
800
|
+
if (pair.FieldName?.toLowerCase().trim() !== pkField.Name.toLowerCase().trim()) {
|
|
801
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'AfterKeyShape', `AfterKey key name "${pair.FieldName}" does not match the entity's PK column "${pkField.Name}".`);
|
|
802
|
+
}
|
|
803
|
+
if (pair.Value === null || pair.Value === undefined || pair.Value === '') {
|
|
804
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'AfterKeyShape', `AfterKey value for "${pkField.Name}" is null/empty. To request the first page, omit AfterKey entirely.`);
|
|
805
|
+
}
|
|
806
|
+
// 5. OrderBy compatibility — must be empty, or reference only the PK column
|
|
807
|
+
let direction = 'ASC';
|
|
808
|
+
const rawOrderBy = params.OrderBy?.trim() ?? '';
|
|
809
|
+
if (rawOrderBy.length > 0) {
|
|
810
|
+
// Accept: "ID", "ID ASC", "ID DESC", "[ID]", "[ID] DESC", '"ID"', etc.
|
|
811
|
+
// Strip quoting characters and split on whitespace.
|
|
812
|
+
const stripped = rawOrderBy.replace(/[\[\]"`]/g, '').trim();
|
|
813
|
+
const tokens = stripped.split(/\s+/);
|
|
814
|
+
const colName = tokens[0]?.trim();
|
|
815
|
+
const dirToken = tokens[1]?.toUpperCase().trim();
|
|
816
|
+
if (!colName || colName.toLowerCase() !== pkField.Name.toLowerCase()) {
|
|
817
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'IncompatibleOrderBy', `When AfterKey is set, OrderBy must reference only the PK column "${pkField.Name}". Got "${rawOrderBy}".`);
|
|
818
|
+
}
|
|
819
|
+
if (tokens.length > 2 || (dirToken && dirToken !== 'ASC' && dirToken !== 'DESC')) {
|
|
820
|
+
throw new AfterKeyNotSupportedError(entityInfo.Name, 'IncompatibleOrderBy', `When AfterKey is set, OrderBy may only specify a direction (ASC/DESC). Got "${rawOrderBy}".`);
|
|
821
|
+
}
|
|
822
|
+
if (dirToken === 'DESC')
|
|
823
|
+
direction = 'DESC';
|
|
824
|
+
}
|
|
825
|
+
// 6. Build the seek predicate. Comparison operator depends on direction.
|
|
826
|
+
const op = direction === 'ASC' ? '>' : '<';
|
|
827
|
+
const pkColumnName = this.QuoteIdentifier(pkField.Name);
|
|
828
|
+
const literalValue = this.formatKeysetSeekValue(pair.Value, pkField.Type);
|
|
829
|
+
const seekPredicate = `${pkColumnName} ${op} ${literalValue}`;
|
|
830
|
+
return { seekPredicate, direction, pkColumnName };
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Formats a CompositeKey value as a SQL literal for use in a keyset seek predicate.
|
|
834
|
+
*
|
|
835
|
+
* This bypasses parameter binding because the entire WHERE clause is built as a string
|
|
836
|
+
* elsewhere in this provider (consistent with ExtraFilter / OrderBy handling). The seek
|
|
837
|
+
* value comes from server-side application code, not raw user input — but we still type-
|
|
838
|
+
* check and escape to defend against any caller passing a tainted value.
|
|
839
|
+
*
|
|
840
|
+
* Strategy:
|
|
841
|
+
* - UUID types: validate strict UUID format, wrap in single quotes
|
|
842
|
+
* - Numeric types: validate finite number, output bare
|
|
843
|
+
* - Boolean: output 0/1 (SQL Server) or TRUE/FALSE (caller can override if needed)
|
|
844
|
+
* - Date/time types: validate ISO-ish string, wrap in single quotes
|
|
845
|
+
* - String types: escape single quotes by doubling, wrap in single quotes
|
|
846
|
+
*
|
|
847
|
+
* @throws AfterKeyNotSupportedError if the value fails type-specific validation
|
|
848
|
+
*/
|
|
849
|
+
formatKeysetSeekValue(value, sqlType) {
|
|
850
|
+
const t = (sqlType || '').replace(/\s*\([^)]*\)\s*$/, '').trim().toLowerCase();
|
|
851
|
+
const asStr = String(value);
|
|
852
|
+
// UUIDs — validate format strictly
|
|
853
|
+
if (t === 'uniqueidentifier' || t === 'uuid') {
|
|
854
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
855
|
+
if (!uuidPattern.test(asStr)) {
|
|
856
|
+
throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value "${asStr}" is not a valid UUID for ${sqlType} primary key.`);
|
|
857
|
+
}
|
|
858
|
+
return `'${asStr}'`;
|
|
859
|
+
}
|
|
860
|
+
// Numeric types
|
|
861
|
+
const numericTypes = new Set([
|
|
862
|
+
'int', 'bigint', 'smallint', 'tinyint', 'integer', 'bigserial', 'serial',
|
|
863
|
+
'decimal', 'numeric', 'money', 'smallmoney', 'float', 'real', 'double precision'
|
|
864
|
+
]);
|
|
865
|
+
if (numericTypes.has(t)) {
|
|
866
|
+
const n = typeof value === 'number' ? value : Number(asStr);
|
|
867
|
+
if (!Number.isFinite(n)) {
|
|
868
|
+
throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value "${asStr}" is not a valid numeric value for ${sqlType} primary key.`);
|
|
869
|
+
}
|
|
870
|
+
return String(n);
|
|
871
|
+
}
|
|
872
|
+
// Boolean / bit
|
|
873
|
+
if (t === 'bit' || t === 'boolean') {
|
|
874
|
+
const v = value === true || asStr === 'true' || asStr === '1';
|
|
875
|
+
return v ? '1' : '0';
|
|
876
|
+
}
|
|
877
|
+
// Date / time — accept anything ISO-ish; quote and escape single quotes
|
|
878
|
+
const dateTypes = new Set([
|
|
879
|
+
'date', 'datetime', 'datetime2', 'datetimeoffset', 'smalldatetime', 'time',
|
|
880
|
+
'timestamp', 'timestamp with time zone', 'timestamp without time zone'
|
|
881
|
+
]);
|
|
882
|
+
if (dateTypes.has(t)) {
|
|
883
|
+
const escaped = asStr.replace(/'/g, "''");
|
|
884
|
+
// Reject anything containing semicolons or comment markers as a defense-in-depth check
|
|
885
|
+
if (/;|--|\/\*|\*\//.test(escaped)) {
|
|
886
|
+
throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value for ${sqlType} primary key contains disallowed characters.`);
|
|
887
|
+
}
|
|
888
|
+
return `'${escaped}'`;
|
|
889
|
+
}
|
|
890
|
+
// String types (default)
|
|
891
|
+
const escaped = asStr.replace(/'/g, "''");
|
|
892
|
+
if (/;|--|\/\*|\*\//.test(escaped)) {
|
|
893
|
+
throw new AfterKeyNotSupportedError('', 'AfterKeyShape', `AfterKey value contains disallowed characters.`);
|
|
894
|
+
}
|
|
895
|
+
return `'${escaped}'`;
|
|
896
|
+
}
|
|
727
897
|
/**
|
|
728
898
|
* Transforms a user-provided SQL clause (ExtraFilter, OrderBy, etc.) for platform compatibility.
|
|
729
899
|
* PostgreSQL overrides to quote mixed-case identifiers and convert bracket notation.
|
|
@@ -786,7 +956,21 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
786
956
|
const overrideExcludeFilter = params.OverrideExcludeFilter ?? '';
|
|
787
957
|
const saveViewResults = params.SaveViewResults ?? false;
|
|
788
958
|
// ── TOP / pagination mode ──
|
|
789
|
-
|
|
959
|
+
// Keyset (AfterKey) takes precedence: validate now so failures are early, predictable,
|
|
960
|
+
// and never silently degrade to OFFSET. Throws AfterKeyNotSupportedError if invalid.
|
|
961
|
+
const usingKeyset = !!params.AfterKey;
|
|
962
|
+
let keysetSeekPredicate = '';
|
|
963
|
+
let keysetDirection = 'ASC';
|
|
964
|
+
let keysetPkColumnName = '';
|
|
965
|
+
if (usingKeyset) {
|
|
966
|
+
const seek = this.BuildKeysetSeekClause(entityInfo, params);
|
|
967
|
+
keysetSeekPredicate = seek.seekPredicate;
|
|
968
|
+
keysetDirection = seek.direction;
|
|
969
|
+
keysetPkColumnName = seek.pkColumnName;
|
|
970
|
+
}
|
|
971
|
+
// usingPagination is the OFFSET-based path; keyset uses TOP/LIMIT semantics like a
|
|
972
|
+
// non-paginated query, so we never set usingPagination=true when AfterKey is present.
|
|
973
|
+
const usingPagination = !usingKeyset && !!(params.MaxRows && params.MaxRows > 0 && params.StartRow !== undefined && params.StartRow >= 0);
|
|
790
974
|
let topSQL = '';
|
|
791
975
|
let maxRowsForQuery = 0;
|
|
792
976
|
if (params.IgnoreMaxRows === true) {
|
|
@@ -796,6 +980,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
796
980
|
// pagination — no TOP, will add OFFSET/FETCH or LIMIT/OFFSET later
|
|
797
981
|
maxRowsForQuery = params.MaxRows;
|
|
798
982
|
}
|
|
983
|
+
else if (usingKeyset) {
|
|
984
|
+
// Keyset uses TOP/LIMIT like a non-paginated query — bounded by MaxRows.
|
|
985
|
+
// MaxRows is required for keyset to make sense (otherwise return whole table).
|
|
986
|
+
const keysetMaxRows = params.MaxRows && params.MaxRows > 0 ? params.MaxRows : (entityInfo.UserViewMaxRows && entityInfo.UserViewMaxRows > 0 ? entityInfo.UserViewMaxRows : 1000);
|
|
987
|
+
topSQL = this.BuildTopClause(keysetMaxRows);
|
|
988
|
+
maxRowsForQuery = keysetMaxRows;
|
|
989
|
+
}
|
|
799
990
|
else if (params.MaxRows && params.MaxRows > 0) {
|
|
800
991
|
topSQL = this.BuildTopClause(params.MaxRows);
|
|
801
992
|
maxRowsForQuery = params.MaxRows;
|
|
@@ -859,14 +1050,32 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
859
1050
|
bHasWhere = true;
|
|
860
1051
|
}
|
|
861
1052
|
}
|
|
1053
|
+
// 6. Keyset (AfterKey) seek predicate — only on the data query, NOT the count query.
|
|
1054
|
+
// The count is the total matching the user-visible filters; the seek predicate is
|
|
1055
|
+
// a pagination cursor, conceptually distinct from "how many records match overall".
|
|
1056
|
+
const seekPredicateForData = usingKeyset ? keysetSeekPredicate : '';
|
|
862
1057
|
if (bHasWhere) {
|
|
863
1058
|
viewSQL += ` WHERE ${whereSQL}`;
|
|
864
1059
|
if (countSQL)
|
|
865
1060
|
countSQL += ` WHERE ${whereSQL}`;
|
|
866
1061
|
}
|
|
1062
|
+
if (seekPredicateForData) {
|
|
1063
|
+
viewSQL += bHasWhere ? ` AND (${seekPredicateForData})` : ` WHERE (${seekPredicateForData})`;
|
|
1064
|
+
}
|
|
867
1065
|
// ── ORDER BY (transform user-provided clause for platform compatibility) ──
|
|
868
|
-
|
|
869
|
-
|
|
1066
|
+
// For keyset (AfterKey) mode, we force ORDER BY <pk> <direction> regardless of the
|
|
1067
|
+
// caller's OrderBy — BuildKeysetSeekClause already validated that any caller-provided
|
|
1068
|
+
// OrderBy referenced the PK and we use the resolved direction.
|
|
1069
|
+
let rawOrderBy;
|
|
1070
|
+
if (usingKeyset) {
|
|
1071
|
+
rawOrderBy = `${keysetPkColumnName} ${keysetDirection}`;
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
rawOrderBy = params.OrderBy ? params.OrderBy : (viewEntity ? viewEntity.OrderByClause ?? '' : '');
|
|
1075
|
+
}
|
|
1076
|
+
const orderBy = rawOrderBy.length > 0
|
|
1077
|
+
? (usingKeyset ? rawOrderBy : this.TransformExternalSQLClause(rawOrderBy, entityInfo))
|
|
1078
|
+
: '';
|
|
870
1079
|
// View run logging (SQL Server-specific, others return null)
|
|
871
1080
|
let userViewRunID = '';
|
|
872
1081
|
if (viewEntity?.ID && String(viewEntity.ID).length > 0 && saveViewResults && user) {
|
|
@@ -876,13 +1085,13 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
876
1085
|
userViewRunID = logResult.runID;
|
|
877
1086
|
}
|
|
878
1087
|
else if (orderBy.length > 0) {
|
|
879
|
-
if (!this.ValidateUserProvidedSQLClause(orderBy))
|
|
1088
|
+
if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
|
|
880
1089
|
throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
|
|
881
1090
|
viewSQL += ` ORDER BY ${orderBy}`;
|
|
882
1091
|
}
|
|
883
1092
|
}
|
|
884
1093
|
else if (orderBy.length > 0) {
|
|
885
|
-
if (!this.ValidateUserProvidedSQLClause(orderBy))
|
|
1094
|
+
if (!usingKeyset && !this.ValidateUserProvidedSQLClause(orderBy))
|
|
886
1095
|
throw new Error(`Invalid Order By clause: ${orderBy}, contains one more for forbidden keywords`);
|
|
887
1096
|
viewSQL += ` ORDER BY ${orderBy}`;
|
|
888
1097
|
}
|
|
@@ -894,7 +1103,9 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
894
1103
|
viewSQL += ' ' + this.BuildPaginationSQL(params.MaxRows, params.StartRow);
|
|
895
1104
|
}
|
|
896
1105
|
else if (!topSQL && maxRowsForQuery > 0) {
|
|
897
|
-
// Platform doesn't use TOP (e.g., PG uses LIMIT at end of query)
|
|
1106
|
+
// Platform doesn't use TOP (e.g., PG uses LIMIT at end of query).
|
|
1107
|
+
// This also covers the usingKeyset case on PG: topSQL is set to empty by PG's
|
|
1108
|
+
// BuildTopClause, so we fall here and emit LIMIT N at the end.
|
|
898
1109
|
const limitSQL = this.BuildNonPaginatedLimitSQL(maxRowsForQuery);
|
|
899
1110
|
if (limitSQL)
|
|
900
1111
|
viewSQL += ' ' + limitSQL;
|
|
@@ -1082,7 +1293,14 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1082
1293
|
}
|
|
1083
1294
|
/**
|
|
1084
1295
|
* Builds user search SQL for the given entity and search string.
|
|
1296
|
+
*
|
|
1085
1297
|
* Supports full-text search (if enabled) and field-by-field LIKE searching.
|
|
1298
|
+
* For the LIKE path:
|
|
1299
|
+
* - honors EntityField.UserSearchPredicateAPI (Exact / BeginsWith / EndsWith / Contains)
|
|
1300
|
+
* so index-seekable predicates can be expressed,
|
|
1301
|
+
* - escapes LIKE metacharacters (%, _, [, ]) in user input with ESCAPE '\\',
|
|
1302
|
+
* - skips fields that are not sensible text-search targets (non-text types,
|
|
1303
|
+
* unbounded text columns when FTX is off).
|
|
1086
1304
|
*/
|
|
1087
1305
|
createViewUserSearchSQL(entityInfo, userSearchString) {
|
|
1088
1306
|
let sUserSearchSQL = '';
|
|
@@ -1106,23 +1324,78 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1106
1324
|
sUserSearchSQL = `${pkName} IN (SELECT ${pkName} FROM ${this.QuoteSchemaAndView(entityInfo.SchemaName, entityInfo.FullTextSearchFunction ?? '')}('${u}'))`;
|
|
1107
1325
|
}
|
|
1108
1326
|
else {
|
|
1327
|
+
const escapedTerm = this.escapeLikeTerm(safeUserSearchString);
|
|
1109
1328
|
for (const field of entityInfo.Fields) {
|
|
1110
|
-
if (field.IncludeInUserSearchAPI)
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
sUserSearchSQL += `(${this.QuoteIdentifier(field.Name)} ${sParam})`;
|
|
1119
|
-
}
|
|
1329
|
+
if (!field.IncludeInUserSearchAPI)
|
|
1330
|
+
continue;
|
|
1331
|
+
const sParam = this.buildPerFieldSearchPredicate(field, escapedTerm, safeUserSearchString);
|
|
1332
|
+
if (!sParam)
|
|
1333
|
+
continue;
|
|
1334
|
+
if (sUserSearchSQL.length > 0)
|
|
1335
|
+
sUserSearchSQL += ' OR ';
|
|
1336
|
+
sUserSearchSQL += `(${this.QuoteIdentifier(field.Name)} ${sParam})`;
|
|
1120
1337
|
}
|
|
1121
1338
|
if (sUserSearchSQL.length > 0)
|
|
1122
1339
|
sUserSearchSQL = '(' + sUserSearchSQL + ')';
|
|
1123
1340
|
}
|
|
1124
1341
|
return sUserSearchSQL;
|
|
1125
1342
|
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Build the SQL fragment that compares one EntityField against a user search term.
|
|
1345
|
+
*
|
|
1346
|
+
* Resolution order:
|
|
1347
|
+
* 1. UserSearchParamFormatAPI (custom override) wins if set, with `{0}` replaced
|
|
1348
|
+
* by the single-quote-escaped raw term. Caller is responsible for escaping
|
|
1349
|
+
* LIKE metacharacters in their format string if needed.
|
|
1350
|
+
* 2. Otherwise, the field must be a text-searchable type. Non-text and unbounded-
|
|
1351
|
+
* text fields return '' (caller skips them).
|
|
1352
|
+
* 3. Otherwise, the predicate is chosen from UserSearchPredicateAPI:
|
|
1353
|
+
* Exact -> = N'term' (index-seekable)
|
|
1354
|
+
* BeginsWith -> LIKE N'term%' ESCAPE '\\' (index-seekable)
|
|
1355
|
+
* EndsWith -> LIKE N'%term' ESCAPE '\\'
|
|
1356
|
+
* Contains -> LIKE N'%term%' ESCAPE '\\' (default, non-seekable)
|
|
1357
|
+
*/
|
|
1358
|
+
buildPerFieldSearchPredicate(field, escapedTerm, rawSafeTerm) {
|
|
1359
|
+
if (field.UserSearchParamFormatAPI && field.UserSearchParamFormatAPI.length > 0) {
|
|
1360
|
+
return field.UserSearchParamFormatAPI.replace('{0}', rawSafeTerm);
|
|
1361
|
+
}
|
|
1362
|
+
if (!this.isTextSearchableType(field))
|
|
1363
|
+
return '';
|
|
1364
|
+
const pred = (field.UserSearchPredicateAPI ?? 'Contains').trim();
|
|
1365
|
+
switch (pred) {
|
|
1366
|
+
case 'Exact':
|
|
1367
|
+
return ` = N'${rawSafeTerm}'`;
|
|
1368
|
+
case 'BeginsWith':
|
|
1369
|
+
return ` LIKE N'${escapedTerm}%' ESCAPE '\\'`;
|
|
1370
|
+
case 'EndsWith':
|
|
1371
|
+
return ` LIKE N'%${escapedTerm}' ESCAPE '\\'`;
|
|
1372
|
+
case 'Contains':
|
|
1373
|
+
default:
|
|
1374
|
+
return ` LIKE N'%${escapedTerm}%' ESCAPE '\\'`;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Escape characters that have special meaning in SQL Server LIKE patterns.
|
|
1379
|
+
* The backslash itself must be escaped first so its replacement isn't reprocessed.
|
|
1380
|
+
* Pair with `ESCAPE '\\'` on the LIKE clause.
|
|
1381
|
+
*/
|
|
1382
|
+
escapeLikeTerm(safeTerm) {
|
|
1383
|
+
return safeTerm.replace(/[\\%_\[\]]/g, m => '\\' + m);
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* True if the field's column type is appropriate for text-pattern search.
|
|
1387
|
+
* Non-text types are rejected because LIKE forces an implicit per-row CONVERT
|
|
1388
|
+
* to nvarchar. Unbounded (MAX/ntext/text) columns are rejected because the
|
|
1389
|
+
* LIKE path cannot seek them; FTX is the right tool for those.
|
|
1390
|
+
*/
|
|
1391
|
+
isTextSearchableType(field) {
|
|
1392
|
+
const t = (field.Type ?? '').toLowerCase();
|
|
1393
|
+
if (t !== 'nvarchar' && t !== 'varchar' && t !== 'char' && t !== 'nchar')
|
|
1394
|
+
return false;
|
|
1395
|
+
if (field.Length === -1)
|
|
1396
|
+
return false;
|
|
1397
|
+
return true;
|
|
1398
|
+
}
|
|
1126
1399
|
/**************************************************************************/
|
|
1127
1400
|
// RenderViewWhereClause — View Template Rendering
|
|
1128
1401
|
/**************************************************************************/
|
|
@@ -1177,6 +1450,24 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1177
1450
|
/**************************************************************************/
|
|
1178
1451
|
// Cache Check Utilities
|
|
1179
1452
|
/**************************************************************************/
|
|
1453
|
+
/**
|
|
1454
|
+
* Parses a timestamp string sent by the client.
|
|
1455
|
+
*
|
|
1456
|
+
* Accepts both ISO 8601 strings (the canonical form) and all-digit strings
|
|
1457
|
+
* representing milliseconds since epoch. The numeric form has been observed
|
|
1458
|
+
* in the wild from clients whose cache layer round-tripped a Date through a
|
|
1459
|
+
* lossy serializer — `new Date('1778004618383')` returns `Invalid Date`,
|
|
1460
|
+
* but `new Date(Number('1778004618383'))` is a valid timestamp. Returning
|
|
1461
|
+
* `null` on unparseable input lets callers degrade to a stale-cache fallback
|
|
1462
|
+
* instead of throwing `RangeError: Invalid time value` on a downstream
|
|
1463
|
+
* `.toISOString()`.
|
|
1464
|
+
*/
|
|
1465
|
+
parseClientTimestamp(raw) {
|
|
1466
|
+
if (!raw)
|
|
1467
|
+
return null;
|
|
1468
|
+
const d = /^\d+$/.test(raw) ? new Date(Number(raw)) : new Date(raw);
|
|
1469
|
+
return isNaN(d.getTime()) ? null : d;
|
|
1470
|
+
}
|
|
1180
1471
|
/**
|
|
1181
1472
|
* Compares client cache status with server status to determine if cache is current.
|
|
1182
1473
|
* Checks both row count and maxUpdatedAt timestamp.
|
|
@@ -1184,11 +1475,23 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1184
1475
|
isCacheCurrent(clientStatus, serverStatus) {
|
|
1185
1476
|
if (clientStatus.rowCount !== serverStatus.rowCount)
|
|
1186
1477
|
return false;
|
|
1187
|
-
|
|
1478
|
+
// Handle empty result sets: if both sides report no timestamp, matching row count is sufficient
|
|
1479
|
+
const clientEmpty = !clientStatus.maxUpdatedAt || clientStatus.maxUpdatedAt === '';
|
|
1480
|
+
const serverEmpty = !serverStatus.maxUpdatedAt || serverStatus.maxUpdatedAt === '';
|
|
1481
|
+
if (clientEmpty && serverEmpty)
|
|
1482
|
+
return true;
|
|
1483
|
+
const clientDate = this.parseClientTimestamp(clientStatus.maxUpdatedAt);
|
|
1484
|
+
if (!clientDate)
|
|
1485
|
+
return false;
|
|
1188
1486
|
const serverDate = serverStatus.maxUpdatedAt ? new Date(serverStatus.maxUpdatedAt) : null;
|
|
1487
|
+
// Server has no timestamp (e.g., empty table) — current if row counts match (already checked above)
|
|
1189
1488
|
if (!serverDate)
|
|
1190
1489
|
return clientStatus.rowCount === 0;
|
|
1191
|
-
|
|
1490
|
+
if (isNaN(serverDate.getTime()))
|
|
1491
|
+
return false;
|
|
1492
|
+
// Compare as epoch milliseconds with 1-second tolerance to handle timestamp
|
|
1493
|
+
// precision differences between SQL Server and JavaScript Date.toISOString()
|
|
1494
|
+
return Math.abs(clientDate.getTime() - serverDate.getTime()) < 1000;
|
|
1192
1495
|
}
|
|
1193
1496
|
/**************************************************************************/
|
|
1194
1497
|
// RunViewsWithCacheCheck — Shared Implementation
|
|
@@ -1212,7 +1515,10 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1212
1515
|
const errorResults = [];
|
|
1213
1516
|
for (let i = 0; i < params.length; i++) {
|
|
1214
1517
|
const item = params[i];
|
|
1215
|
-
|
|
1518
|
+
// Keyset queries bypass the cache entirely (per the AfterKey API contract):
|
|
1519
|
+
// each call uses a different seek key, so cached entries would never be
|
|
1520
|
+
// reusable. Route directly to standard execution path.
|
|
1521
|
+
if (!item.cacheStatus || item.params.AfterKey) {
|
|
1216
1522
|
itemsWithoutCacheCheck.push({ index: i, item });
|
|
1217
1523
|
continue;
|
|
1218
1524
|
}
|
|
@@ -1466,12 +1772,17 @@ export class GenericDatabaseProvider extends DatabaseProviderBase {
|
|
|
1466
1772
|
const updatedRows = await this.getUpdatedRowsSince(params, entityInfo, clientMaxUpdatedAt, whereSQL, contextUser);
|
|
1467
1773
|
const deletedRecordIDs = await this.getDeletedRecordIDsSince(entityInfo.ID, clientMaxUpdatedAt, contextUser);
|
|
1468
1774
|
// Validation: detect hidden deletes not tracked in RecordChanges
|
|
1469
|
-
const clientMaxUpdatedDate =
|
|
1775
|
+
const clientMaxUpdatedDate = this.parseClientTimestamp(clientMaxUpdatedAt);
|
|
1776
|
+
if (!clientMaxUpdatedDate) {
|
|
1777
|
+
// Unparseable client timestamp — can't trust the differential math; fall back.
|
|
1778
|
+
return this.runFullQueryAndReturn(params, viewIndex, contextUser);
|
|
1779
|
+
}
|
|
1470
1780
|
const newInserts = updatedRows.filter(row => {
|
|
1471
1781
|
const createdAt = row['__mj_CreatedAt'];
|
|
1472
1782
|
if (!createdAt)
|
|
1473
1783
|
return false;
|
|
1474
|
-
|
|
1784
|
+
const created = new Date(String(createdAt));
|
|
1785
|
+
return !isNaN(created.getTime()) && created > clientMaxUpdatedDate;
|
|
1475
1786
|
}).length;
|
|
1476
1787
|
const serverRowCount = serverStatus.rowCount ?? 0;
|
|
1477
1788
|
const impliedDeletes = clientRowCount + newInserts - serverRowCount;
|