@genesislcap/mock-server 15.52.0 → 15.53.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 +210 -27
- package/dist/db/criteria.d.ts +12 -0
- package/dist/db/criteria.js +39 -29
- package/dist/db/ordering.d.ts +8 -0
- package/dist/db/ordering.js +49 -0
- package/dist/db/schema.d.ts +17 -1
- package/dist/db/schema.js +131 -16
- package/dist/db/store.d.ts +8 -2
- package/dist/db/store.js +120 -44
- package/dist/handlers/commitEvent.js +32 -8
- package/dist/handlers/crudEvents.d.ts +2 -0
- package/dist/handlers/crudEvents.js +138 -0
- package/dist/handlers/dataLogon.js +2 -23
- package/dist/handlers/eventValidation.d.ts +5 -0
- package/dist/handlers/eventValidation.js +29 -0
- package/dist/handlers/jsonSchema.d.ts +3 -2
- package/dist/handlers/jsonSchema.js +23 -17
- package/dist/handlers/meta.js +13 -9
- package/dist/handlers/requestReply.js +518 -40
- package/dist/handlers/resources.js +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/protocol/fieldTypes.d.ts +1 -0
- package/dist/protocol/fieldTypes.js +6 -0
- package/dist/protocol/msgNack.d.ts +2 -0
- package/dist/protocol/msgNack.js +10 -0
- package/dist/protocol/schemaValidation.d.ts +11 -0
- package/dist/protocol/schemaValidation.js +186 -0
- package/dist/server.js +5 -1
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +52 -27
- package/src/db/ordering.ts +56 -0
- package/src/db/schema.ts +164 -15
- package/src/db/store.ts +146 -46
- package/src/handlers/commitEvent.ts +39 -8
- package/src/handlers/crudEvents.ts +181 -0
- package/src/handlers/dataLogon.ts +2 -19
- package/src/handlers/eventValidation.ts +43 -0
- package/src/handlers/jsonSchema.ts +31 -19
- package/src/handlers/meta.ts +14 -9
- package/src/handlers/requestReply.ts +625 -41
- package/src/handlers/resources.ts +3 -0
- package/src/index.ts +1 -0
- package/src/protocol/fieldTypes.ts +7 -0
- package/src/protocol/msgNack.ts +12 -0
- package/src/protocol/schemaValidation.ts +208 -0
- package/src/server.ts +5 -1
- package/src/types.ts +56 -18
|
@@ -1,25 +1,587 @@
|
|
|
1
|
-
|
|
1
|
+
// REQ_<NAME> -> REP_<NAME>, as GSF 8.15.29's request server answers it
|
|
2
|
+
// (genesis-pal-requestserver). A request server is one of two kinds, and they
|
|
3
|
+
// read a request differently:
|
|
4
|
+
//
|
|
5
|
+
// - criteria-only (`criteriaOnly`, criteriaOnlyRequest = true:
|
|
6
|
+
// EntityDbRequestReply.kt): REQUEST must be present but its fields are
|
|
7
|
+
// ignored; CRITERIA_MATCH, ORDER_BY, OFFSET / VIEW_NUMBER and MAX_ROWS run
|
|
8
|
+
// as one database read. Every reply carries MORE_ROWS, and NEXT_OFFSET while
|
|
9
|
+
// there are more (the SQL flavour, as the recordings show).
|
|
10
|
+
// - plain (RequestReply.kt): REQUEST's fields select rows — exact values,
|
|
11
|
+
// `*` wildcards (WildcardFilter.kt), FIELD_FROM/FIELD_TO ranges — and an
|
|
12
|
+
// array REQUEST runs each object and concatenates the results. MAX_ROWS
|
|
13
|
+
// caps each one; OFFSET and ORDER_BY are ignored, and nothing says whether
|
|
14
|
+
// there are more rows.
|
|
15
|
+
//
|
|
16
|
+
// See the README's "Request servers" for every rule and its evidence.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
CriteriaSyntaxError,
|
|
20
|
+
filterByCriteria,
|
|
21
|
+
parseCriteria,
|
|
22
|
+
parseGenesisDateTime,
|
|
23
|
+
UnsupportedCriteriaError,
|
|
24
|
+
type Predicate,
|
|
25
|
+
} from '../db/criteria.ts';
|
|
2
26
|
import { withoutRecordFields } from '../db/identity.ts';
|
|
27
|
+
import { compareValues, parseOrderBy, rowComparator, type OrderedField } from '../db/ordering.ts';
|
|
28
|
+
import {
|
|
29
|
+
pkFieldsOf,
|
|
30
|
+
primaryKeyIndexName,
|
|
31
|
+
projectRow,
|
|
32
|
+
requestReplyFieldNames,
|
|
33
|
+
requestReplySourceSchema,
|
|
34
|
+
type ResolvedField,
|
|
35
|
+
} from '../db/schema.ts';
|
|
3
36
|
import type { Store } from '../db/store.ts';
|
|
4
37
|
import type { Connection } from '../protocol/connection.ts';
|
|
5
|
-
import { nackPayload } from '../protocol/errors.ts';
|
|
38
|
+
import { ErrorCode, NackError, nackPayload } from '../protocol/errors.ts';
|
|
6
39
|
import { isLegacyFidelity } from '../protocol/fidelity.ts';
|
|
40
|
+
import { daoClassOf, viewEntityClassOf } from '../protocol/fieldTypes.ts';
|
|
7
41
|
import { ERROR_CODE } from '../protocol/messageTypes.ts';
|
|
8
|
-
import { resourceNotMappedNack } from '../protocol/msgNack.ts';
|
|
9
|
-
import type {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
42
|
+
import { resourceNotMappedNack, serviceMsgNack } from '../protocol/msgNack.ts';
|
|
43
|
+
import type {
|
|
44
|
+
GenesisMessage,
|
|
45
|
+
MockServerConfig,
|
|
46
|
+
RequestingUser,
|
|
47
|
+
RequestReplyDef,
|
|
48
|
+
Row,
|
|
49
|
+
} from '../types.ts';
|
|
50
|
+
import { applyResourceAuth, permittedQueryRow, requestingUser } from './resourceAuth.ts';
|
|
51
|
+
|
|
52
|
+
// RequestReply.DEFAULT_MAXIMUM_ROWS: the rows a REP_ carries when neither the
|
|
53
|
+
// request (DETAILS.MAX_ROWS) nor the server (rowLimit) says otherwise.
|
|
54
|
+
const DEFAULT_ROW_LIMIT = 10000;
|
|
55
|
+
// Request.DEFAULT_VIEW_NUMBER: 0 is "not paging by view".
|
|
56
|
+
const DEFAULT_VIEW_NUMBER = 0;
|
|
57
|
+
const WILDCARD = '*';
|
|
58
|
+
|
|
59
|
+
// GSF answers an IllegalArgumentException from the request server (a failed
|
|
60
|
+
// require(), a bad ORDER_BY) with StandardError REQUEST_FAILED and its message.
|
|
61
|
+
const requestFailed = (text: string) => NackError.standard(ErrorCode.REQUEST_FAILED, text);
|
|
62
|
+
// Anything else a criteria-only server hits — a REQUEST that doesn't
|
|
63
|
+
// deserialize, a criteria Groovy can't compile — is INTERNAL_ERROR with the
|
|
64
|
+
// code's own text ("Unexpected server error"), the message only in the log.
|
|
65
|
+
const internalError = () => NackError.standard(ErrorCode.INTERNAL_ERROR);
|
|
66
|
+
|
|
67
|
+
const isPlainObject = (value: unknown): value is Row =>
|
|
68
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
|
|
70
|
+
// --- request details ---------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
type Strictness = 'strict' | 'lenient';
|
|
73
|
+
|
|
74
|
+
// An integer DETAILS value. Jackson coerces numeric strings and truncates
|
|
75
|
+
// fractions; anything else fails to deserialize the request (strict: a
|
|
76
|
+
// criteria-only server's Request<Unit>) or reads as absent (lenient: a plain
|
|
77
|
+
// server's GenesisSet.getInteger).
|
|
78
|
+
function readInteger(value: unknown, strictness: Strictness): number | undefined {
|
|
79
|
+
if (value === undefined || value === null) return undefined;
|
|
80
|
+
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
81
|
+
if (typeof number === 'number' && Number.isFinite(number)) return Math.trunc(number);
|
|
82
|
+
if (strictness === 'strict') throw internalError();
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const readText = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
87
|
+
|
|
88
|
+
// --- the source --------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
interface Source {
|
|
91
|
+
name: string;
|
|
92
|
+
// Every field the entity has (its columns, RECORD_ID and TIMESTAMP), by name.
|
|
93
|
+
fields: Map<string, ResolvedField>;
|
|
94
|
+
pkFields: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sourceOf(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): Source {
|
|
98
|
+
const name = requestReply.source!;
|
|
99
|
+
const schema = requestReplySourceSchema(requestReply, config, store);
|
|
100
|
+
return {
|
|
101
|
+
name,
|
|
102
|
+
fields: new Map(schema.fields.map((field) => [field.name, field])),
|
|
103
|
+
pkFields: store.getPkFields(name),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isView(name: string, config: MockServerConfig, store: Store): boolean {
|
|
108
|
+
return store.views.has(name) || !!config.views?.[name];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The generated entity class the rows are (definition.source.entityType):
|
|
112
|
+
// the DAO for a table, the view entity for a view.
|
|
113
|
+
function parametricTypeOf(name: string, config: MockServerConfig, store: Store): string {
|
|
114
|
+
return isView(name, config, store) ? viewEntityClassOf(name) : daoClassOf(name);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const byPrimaryKey = (source: Source): OrderedField[] =>
|
|
118
|
+
source.pkFields.map((field) => ({ field, descending: false }));
|
|
119
|
+
|
|
120
|
+
// --- criteria ----------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
type CriteriaFilter = { kind: 'all' } | { kind: 'none' } | { kind: 'match'; predicate: Predicate };
|
|
123
|
+
|
|
124
|
+
// CRITERIA_MATCH against the entity. A malformed one fails as `onSyntaxError`
|
|
125
|
+
// says (the two server kinds differ). A criteria naming a field the entity
|
|
126
|
+
// doesn't have matches nothing: GSF evaluates it outside the database, where
|
|
127
|
+
// the unknown variable never compares true (recorded: REPLY: []). A call to a
|
|
128
|
+
// function this grammar doesn't model may be valid Groovy, so it still fails
|
|
129
|
+
// open (logged), as everywhere else in the engine.
|
|
130
|
+
function criteriaFilter(
|
|
131
|
+
criteria: string,
|
|
132
|
+
source: Source,
|
|
133
|
+
onSyntaxError: (error: CriteriaSyntaxError) => Error,
|
|
134
|
+
): CriteriaFilter {
|
|
135
|
+
if (criteria.trim() === '') return { kind: 'all' };
|
|
136
|
+
try {
|
|
137
|
+
const { predicate, fields } = parseCriteria(criteria);
|
|
138
|
+
if (fields.some((field) => !source.fields.has(field))) return { kind: 'none' };
|
|
139
|
+
return { kind: 'match', predicate };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof CriteriaSyntaxError) throw onSyntaxError(error);
|
|
142
|
+
if (error instanceof UnsupportedCriteriaError) {
|
|
143
|
+
console.warn(
|
|
144
|
+
`[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`,
|
|
145
|
+
error,
|
|
146
|
+
);
|
|
147
|
+
return { kind: 'all' };
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function applyCriteria(rows: Row[], filter: CriteriaFilter): Row[] {
|
|
154
|
+
if (filter.kind === 'all') return rows;
|
|
155
|
+
if (filter.kind === 'none') return [];
|
|
156
|
+
return rows.filter((row) => {
|
|
157
|
+
try {
|
|
158
|
+
return filter.predicate(row);
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- ORDER_BY ----------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
// The index names ORDER_BY may use instead of fields (OrderSpec.kt
|
|
168
|
+
// replaceIndexWithFields): 'PK', the primary key's index and the table's
|
|
169
|
+
// declared indexes — a view's, its base table's.
|
|
170
|
+
function orderIndexes(
|
|
171
|
+
source: Source,
|
|
172
|
+
config: MockServerConfig,
|
|
173
|
+
store: Store,
|
|
174
|
+
): Map<string, string[]> {
|
|
175
|
+
const indexes = new Map<string, string[]>([['PK', source.pkFields]]);
|
|
176
|
+
const tableName = isView(source.name, config, store)
|
|
177
|
+
? (store.views.get(source.name) ?? config.views?.[source.name])?.base
|
|
178
|
+
: source.name;
|
|
179
|
+
const table = tableName ? config.tables?.[tableName] : undefined;
|
|
180
|
+
if (table && tableName) {
|
|
181
|
+
indexes.set(primaryKeyIndexName(tableName, table), pkFieldsOf(table));
|
|
182
|
+
for (const index of table.indexes ?? []) indexes.set(index.name, index.fields);
|
|
183
|
+
}
|
|
184
|
+
return indexes;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// DETAILS.ORDER_BY on a criteria-only server: fields, each optionally ASC or
|
|
188
|
+
// DESC ('INT_FIELD DESC', 'A, B DESC'). An index name (or 'PK') that isn't
|
|
189
|
+
// also a field stands for its fields. A malformed string, or a field the
|
|
190
|
+
// entity doesn't have, is REQUEST_FAILED with GSF's text
|
|
191
|
+
// (GetByCriteriaOperation.kt prints the Kotlin list of OrderedFields).
|
|
192
|
+
function orderingOf(
|
|
193
|
+
orderBy: string,
|
|
194
|
+
source: Source,
|
|
195
|
+
config: MockServerConfig,
|
|
196
|
+
store: Store,
|
|
197
|
+
): OrderedField[] | undefined {
|
|
198
|
+
if (orderBy.trim() === '') return undefined;
|
|
199
|
+
let fields: OrderedField[];
|
|
200
|
+
try {
|
|
201
|
+
fields = parseOrderBy(orderBy);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw requestFailed((error as Error).message);
|
|
204
|
+
}
|
|
205
|
+
const indexes = orderIndexes(source, config, store);
|
|
206
|
+
const namesIndex = ({ field }: OrderedField) => !source.fields.has(field) && indexes.has(field);
|
|
207
|
+
if (fields.some(namesIndex)) {
|
|
208
|
+
fields = fields.flatMap((ordered) =>
|
|
209
|
+
namesIndex(ordered)
|
|
210
|
+
? indexes.get(ordered.field)!.map((field) => ({ field, descending: ordered.descending }))
|
|
211
|
+
: [ordered],
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const missing = fields.filter(({ field }) => !source.fields.has(field));
|
|
215
|
+
if (missing.length > 0) {
|
|
216
|
+
const listed = missing
|
|
217
|
+
.map(({ field, descending }) => `${descending ? 'Desc' : 'Asc'}(field=${field})`)
|
|
218
|
+
.join(', ');
|
|
219
|
+
throw requestFailed(`Order fields [${listed}] not found on table ${source.name}`);
|
|
220
|
+
}
|
|
221
|
+
return fields;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- criteria-only servers (EntityDbRequestReply.kt) -------------------------
|
|
225
|
+
|
|
226
|
+
interface Page {
|
|
227
|
+
rows: Row[];
|
|
228
|
+
nextOffset?: number;
|
|
229
|
+
moreRows?: boolean;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// EntityDbRequestReply.data(): one row past MAX_ROWS says there are more.
|
|
233
|
+
// NEXT_OFFSET is the request's OFFSET plus how far the read got (offsetShift),
|
|
234
|
+
// or that alone when no OFFSET was sent. A view-numbered page can't tell when
|
|
235
|
+
// it was read through a filter (isNextTokenKnowable false): no MORE_ROWS then.
|
|
236
|
+
function pageOf(
|
|
237
|
+
rows: Row[],
|
|
238
|
+
maxRows: number,
|
|
239
|
+
offset: number | undefined,
|
|
240
|
+
offsetShift: number,
|
|
241
|
+
nextKnowable: boolean,
|
|
242
|
+
): Page {
|
|
243
|
+
if (rows.length === 0) return { rows: [], moreRows: false };
|
|
244
|
+
if (!nextKnowable) return { rows };
|
|
245
|
+
if (rows.length > maxRows) {
|
|
246
|
+
return {
|
|
247
|
+
rows: rows.slice(0, maxRows),
|
|
248
|
+
nextOffset: offset === undefined ? offsetShift : offset + offsetShift,
|
|
249
|
+
moreRows: true,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { rows, moreRows: false };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function criteriaOnlyReply(
|
|
256
|
+
resourceName: string,
|
|
257
|
+
requestReply: RequestReplyDef,
|
|
258
|
+
message: GenesisMessage,
|
|
259
|
+
config: MockServerConfig,
|
|
260
|
+
store: Store,
|
|
261
|
+
user: RequestingUser,
|
|
262
|
+
): Row {
|
|
263
|
+
// The request deserializes as Request<Unit>: without a REQUEST object
|
|
264
|
+
// (missing, null, nested in DETAILS) Jackson throws and the reply is
|
|
265
|
+
// INTERNAL_ERROR (recorded: KotlinInvalidNullException in the server log).
|
|
266
|
+
if (!isPlainObject(message.REQUEST)) throw internalError();
|
|
267
|
+
const details: Row = isPlainObject(message.DETAILS) ? message.DETAILS : {};
|
|
268
|
+
const offset = readInteger(details.OFFSET, 'strict');
|
|
269
|
+
const viewNumber = readInteger(details.VIEW_NUMBER, 'strict') ?? DEFAULT_VIEW_NUMBER;
|
|
270
|
+
const maxRows =
|
|
271
|
+
readInteger(details.MAX_ROWS, 'strict') ?? requestReply.rowLimit ?? DEFAULT_ROW_LIMIT;
|
|
272
|
+
|
|
273
|
+
// EntityDbRequestReply.process's require()s, in order.
|
|
274
|
+
if (offset !== undefined && viewNumber !== DEFAULT_VIEW_NUMBER) {
|
|
275
|
+
throw requestFailed(
|
|
276
|
+
`You must not supply both offset and viewNumber != ${DEFAULT_VIEW_NUMBER}, ` +
|
|
277
|
+
'unable to combine offset and page request',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (offset !== undefined && offset < 0) {
|
|
281
|
+
throw requestFailed(
|
|
282
|
+
'When providing an offset, you must provide an offset greater than or equal to 0',
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (viewNumber < 0) {
|
|
286
|
+
throw requestFailed(
|
|
287
|
+
'When providing a view number, You must provide a view number greater than or equal to 0',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (maxRows <= 0) throw requestFailed('maxRows should be greater than 0');
|
|
291
|
+
|
|
292
|
+
const source = sourceOf(requestReply, config, store);
|
|
293
|
+
const criteria = criteriaFilter(readText(details.CRITERIA_MATCH), source, internalError);
|
|
294
|
+
const ordering = orderingOf(readText(details.ORDER_BY), source, config, store);
|
|
295
|
+
const byView = viewNumber !== DEFAULT_VIEW_NUMBER;
|
|
296
|
+
// A read that skips rows (an OFFSET past 0, or a view number) is a paged
|
|
297
|
+
// read: GetByCriteriaOperation orders it by the primary key when ORDER_BY
|
|
298
|
+
// doesn't say. An unpaged read comes in the table's own order, oldest
|
|
299
|
+
// first (recorded).
|
|
300
|
+
const paged = byView || (offset ?? 0) > 0;
|
|
301
|
+
if (criteria.kind === 'none' && paged) {
|
|
302
|
+
// Criteria evaluated outside the database can't be paged
|
|
303
|
+
// (PagingSpec.pagingNotSupported).
|
|
304
|
+
throw requestFailed('Paging not supported on lazy criteria evaluation');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let rows = applyCriteria(store.getResourceRows(source.name), criteria);
|
|
308
|
+
const order = ordering ?? (paged ? byPrimaryKey(source) : undefined);
|
|
309
|
+
if (order) rows = [...rows].sort(rowComparator(order));
|
|
310
|
+
|
|
311
|
+
const fieldNames = requestReplyFieldNames(requestReply, config, store);
|
|
312
|
+
let page: Page;
|
|
313
|
+
if (!requestReply.filter && !requestReply.auth) {
|
|
314
|
+
// Eager: the database does the paging, reading one row past the page.
|
|
315
|
+
const start = byView ? maxRows * (viewNumber - 1) : (offset ?? 0);
|
|
316
|
+
const read = rows.slice(start, start + maxRows + 1);
|
|
317
|
+
page = pageOf(read, maxRows, offset, maxRows, true);
|
|
318
|
+
page.rows = page.rows.map((row) => projectRow(row, fieldNames));
|
|
319
|
+
} else {
|
|
320
|
+
// Lazy: the filter and permissions run on the rows the database returns,
|
|
321
|
+
// so the read goes on until one row past the page has passed them, and
|
|
322
|
+
// NEXT_OFFSET says how many rows that took. A view-numbered read gets one
|
|
323
|
+
// database page and keeps what passes.
|
|
324
|
+
const window = byView
|
|
325
|
+
? rows.slice(maxRows * (viewNumber - 1), maxRows * viewNumber)
|
|
326
|
+
: rows.slice(offset ?? 0);
|
|
327
|
+
const kept: Row[] = [];
|
|
328
|
+
let readCount = 0;
|
|
329
|
+
for (const row of window) {
|
|
330
|
+
readCount += 1;
|
|
331
|
+
if (requestReply.filter && !requestReply.filter(row)) continue;
|
|
332
|
+
const permitted = permittedQueryRow(row, requestReply.auth, user, fieldNames);
|
|
333
|
+
if (!permitted) continue;
|
|
334
|
+
kept.push(permitted);
|
|
335
|
+
if (kept.length > maxRows) break;
|
|
336
|
+
}
|
|
337
|
+
page = pageOf(kept, maxRows, offset, readCount - 1, !byView);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
REPLY: page.rows,
|
|
342
|
+
PARAMETRIC_TYPE: parametricTypeOf(source.name, config, store),
|
|
343
|
+
MESSAGE_TYPE: `REP_${resourceName}`,
|
|
344
|
+
...(byView && page.rows.length > 0 ? { NEXT_VIEW: viewNumber + 1 } : {}),
|
|
345
|
+
...(page.nextOffset !== undefined ? { NEXT_OFFSET: page.nextOffset } : {}),
|
|
346
|
+
...(page.moreRows !== undefined ? { MORE_ROWS: page.moreRows } : {}),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- plain servers (RequestReply.kt) ----------------------------------------
|
|
351
|
+
|
|
352
|
+
// A REQUEST value as the field's type (DataTypeConverter.convert). Fails for a
|
|
353
|
+
// value the type can't take.
|
|
354
|
+
function convertValue(value: unknown, field: ResolvedField): unknown {
|
|
355
|
+
const fail = () => {
|
|
356
|
+
throw requestFailed(`Unable to convert ${String(value)} to ${field.type} for ${field.name}`);
|
|
357
|
+
};
|
|
358
|
+
switch (field.type) {
|
|
359
|
+
case 'INT':
|
|
360
|
+
case 'SHORT':
|
|
361
|
+
case 'LONG':
|
|
362
|
+
case 'NANO_TIMESTAMP':
|
|
363
|
+
case 'DOUBLE':
|
|
364
|
+
case 'BIGDECIMAL': {
|
|
365
|
+
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
366
|
+
return typeof number === 'number' && Number.isFinite(number) ? number : fail();
|
|
367
|
+
}
|
|
368
|
+
case 'BOOLEAN':
|
|
369
|
+
if (typeof value === 'boolean') return value;
|
|
370
|
+
if (typeof value === 'string' && /^(true|false)$/i.test(value)) {
|
|
371
|
+
return value.toLowerCase() === 'true';
|
|
372
|
+
}
|
|
373
|
+
return fail();
|
|
374
|
+
case 'DATE':
|
|
375
|
+
case 'DATETIME': {
|
|
376
|
+
// yyyyMMdd as well as the criteria formats (yyyyMMdd-HH:mm[:ss[.SSS]],
|
|
377
|
+
// ISO-8601, epoch millis).
|
|
378
|
+
const day = typeof value === 'string' ? /^(\d{4})(\d{2})(\d{2})$/.exec(value) : null;
|
|
379
|
+
if (day) return Date.UTC(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
|
|
380
|
+
return parseGenesisDateTime(value) ?? fail();
|
|
381
|
+
}
|
|
382
|
+
case 'STRING':
|
|
383
|
+
case 'ENUM':
|
|
384
|
+
case 'RAW':
|
|
385
|
+
return typeof value === 'object' ? fail() : String(value);
|
|
386
|
+
default:
|
|
387
|
+
return value;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// What Kotlin's toString() makes of a row value, for wildcard matching: a
|
|
392
|
+
// Double keeps its fraction ("2.0"), a date prints as ISO-8601, null is "null".
|
|
393
|
+
function kotlinString(value: unknown, field: ResolvedField | undefined): string {
|
|
394
|
+
if (value === undefined || value === null) return 'null';
|
|
395
|
+
if (typeof value === 'number') {
|
|
396
|
+
if ((field?.type === 'DATE' || field?.type === 'DATETIME') && Number.isFinite(value)) {
|
|
397
|
+
return new Date(value).toISOString();
|
|
398
|
+
}
|
|
399
|
+
if (field?.type === 'DOUBLE' && Number.isInteger(value)) return value.toFixed(1);
|
|
400
|
+
}
|
|
401
|
+
return String(value);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// WildcardFilter.matchesWildCard: '*' at both ends is "contains", at the start
|
|
405
|
+
// "ends with", at the end "starts with" — all case-sensitive, after trimming
|
|
406
|
+
// every leading and trailing '*' ('*' alone matches anything, null included).
|
|
407
|
+
// A '*' only in the middle matches nothing.
|
|
408
|
+
function matchesWildcard(text: string, wildcard: string): boolean {
|
|
409
|
+
const starts = wildcard.startsWith(WILDCARD);
|
|
410
|
+
const ends = wildcard.endsWith(WILDCARD);
|
|
411
|
+
const trimmed = wildcard.replace(/^\*+|\*+$/g, '');
|
|
412
|
+
if (starts && ends) return text.includes(trimmed);
|
|
413
|
+
return (starts && text.endsWith(trimmed)) || (ends && text.startsWith(trimmed));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const RANGE_SUFFIXES = ['_FROM', '_TO'];
|
|
417
|
+
|
|
418
|
+
// tryRemoveRangeSuffix: a key that isn't a field may name one's range end.
|
|
419
|
+
function fieldOfKey(key: string, source: Source): string {
|
|
420
|
+
if (source.fields.has(key)) return key;
|
|
421
|
+
let field = key;
|
|
422
|
+
for (const suffix of RANGE_SUFFIXES) {
|
|
423
|
+
if (field.endsWith(suffix)) field = field.slice(0, -suffix.length);
|
|
424
|
+
}
|
|
425
|
+
return field;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// One REQUEST object as a row predicate (RequestReplyBuilderImpl
|
|
429
|
+
// buildFieldPredicates + FieldPredicate.buildPredicate, then the
|
|
430
|
+
// WildcardFilter): undefined when it can't match anything (NoData).
|
|
431
|
+
// - A string containing '*' is a wildcard on that field.
|
|
432
|
+
// - Any other value must equal the field's (converted to its type; null
|
|
433
|
+
// matches null, and nothing on a non-null field).
|
|
434
|
+
// - FIELD_FROM and FIELD_TO together are an inclusive range; either alone,
|
|
435
|
+
// or a value it can't convert, matches nothing.
|
|
436
|
+
// - A field the entity doesn't have matches nothing — unless its value is
|
|
437
|
+
// null (always true of an unknown field) or a wildcard (it reads as "null").
|
|
438
|
+
function requestPredicate(request: Row, source: Source): Predicate | undefined {
|
|
439
|
+
const checks: Predicate[] = [];
|
|
440
|
+
const byField = new Map<string, Array<[string, unknown]>>();
|
|
441
|
+
for (const [key, value] of Object.entries(request)) {
|
|
442
|
+
if (typeof value === 'string' && value.includes(WILDCARD)) {
|
|
443
|
+
const field = source.fields.get(key);
|
|
444
|
+
checks.push((row) => matchesWildcard(kotlinString(row[key], field), value));
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const field = fieldOfKey(key, source);
|
|
448
|
+
byField.set(field, [...(byField.get(field) ?? []), [key, value]]);
|
|
449
|
+
}
|
|
450
|
+
for (const [name, values] of byField) {
|
|
451
|
+
const field = source.fields.get(name);
|
|
452
|
+
if (!field) {
|
|
453
|
+
if (values[0][1] === null) continue;
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
if (values.length === 1) {
|
|
457
|
+
const [key, value] = values[0];
|
|
458
|
+
if (key !== name) return undefined; // an incomplete range
|
|
459
|
+
if (value === null) {
|
|
460
|
+
if (!field.nullable) return undefined;
|
|
461
|
+
checks.push((row) => row[name] === undefined || row[name] === null);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const expected = convertValue(value, field);
|
|
465
|
+
checks.push((row) => compareValues(row[name], expected) === 0);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (values.length > 2) return undefined;
|
|
469
|
+
const lookup = new Map(values);
|
|
470
|
+
const range = [lookup.get(`${name}_FROM`), lookup.get(`${name}_TO`)].map((value) => {
|
|
471
|
+
if (value === undefined || value === null) return undefined;
|
|
472
|
+
try {
|
|
473
|
+
return convertValue(value, field);
|
|
474
|
+
} catch {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
const [from, to] = range;
|
|
479
|
+
if (from === undefined || to === undefined) return undefined;
|
|
480
|
+
checks.push((row) => {
|
|
481
|
+
const value = row[name];
|
|
482
|
+
if (value === undefined || value === null) return false;
|
|
483
|
+
return compareValues(value, from) >= 0 && compareValues(value, to) <= 0;
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return (row) => checks.every((check) => check(row));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function plainReply(
|
|
490
|
+
resourceName: string,
|
|
491
|
+
requestReply: RequestReplyDef,
|
|
492
|
+
message: GenesisMessage,
|
|
493
|
+
config: MockServerConfig,
|
|
494
|
+
store: Store,
|
|
495
|
+
user: RequestingUser,
|
|
496
|
+
): Row {
|
|
497
|
+
const details: Row = isPlainObject(message.DETAILS) ? message.DETAILS : {};
|
|
498
|
+
const rowLimit =
|
|
499
|
+
readInteger(details.MAX_ROWS, 'lenient') ?? requestReply.rowLimit ?? DEFAULT_ROW_LIMIT;
|
|
500
|
+
// Flowable.take(-1)'s IllegalArgumentException.
|
|
501
|
+
if (rowLimit < 0) throw requestFailed(`count >= 0 required but it was ${rowLimit}`);
|
|
502
|
+
// REQUEST is top level only (DETAILS.REQUEST is never read). An array runs
|
|
503
|
+
// each of its objects in turn; anything else is one request, empty when
|
|
504
|
+
// missing.
|
|
505
|
+
const requests: Row[] = Array.isArray(message.REQUEST)
|
|
506
|
+
? message.REQUEST.filter((request) => request !== null && request !== undefined).map(
|
|
507
|
+
(request) => (isPlainObject(request) ? request : {}),
|
|
508
|
+
)
|
|
509
|
+
: [isPlainObject(message.REQUEST) ? message.REQUEST : {}];
|
|
510
|
+
|
|
511
|
+
const source = sourceOf(requestReply, config, store);
|
|
512
|
+
// Here a criteria that doesn't compile fails the whole request with its
|
|
513
|
+
// message (handleThrowable: REQUEST_FAILED).
|
|
514
|
+
const criteria = criteriaFilter(readText(details.CRITERIA_MATCH), source, (error) =>
|
|
515
|
+
requestFailed(error.message),
|
|
516
|
+
);
|
|
517
|
+
const fieldNames = requestReplyFieldNames(requestReply, config, store);
|
|
518
|
+
// The rows come through the table's (or the view's root table's) primary
|
|
519
|
+
// key, as getBulk reads them (recorded: auth's RIGHT, in CODE order).
|
|
520
|
+
const ordered = [...store.getResourceRows(source.name)].sort(rowComparator(byPrimaryKey(source)));
|
|
521
|
+
const candidates = requestReply.filter ? ordered.filter(requestReply.filter) : ordered;
|
|
522
|
+
|
|
523
|
+
const reply = requests.flatMap((request) => {
|
|
524
|
+
const matches = requestPredicate(request, source);
|
|
525
|
+
if (!matches) return [];
|
|
526
|
+
const rows: Row[] = [];
|
|
527
|
+
for (const row of applyCriteria(candidates.filter(matches), criteria)) {
|
|
528
|
+
if (rows.length >= rowLimit) break;
|
|
529
|
+
const permitted = permittedQueryRow(row, requestReply.auth, user, fieldNames);
|
|
530
|
+
if (permitted) rows.push(permitted);
|
|
531
|
+
}
|
|
532
|
+
return rows;
|
|
22
533
|
});
|
|
534
|
+
|
|
535
|
+
// In RequestReply.kt's order: SOURCE_REF straight after MESSAGE_TYPE
|
|
536
|
+
// (connection.reply overwrites it with the same value, keeping its place).
|
|
537
|
+
return {
|
|
538
|
+
MESSAGE_TYPE: `REP_${resourceName}`,
|
|
539
|
+
SOURCE_REF: message.SOURCE_REF,
|
|
540
|
+
PARAMETRIC_TYPE: parametricTypeOf(source.name, config, store),
|
|
541
|
+
REPLY: reply,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// --- resolvers (custom request servers) -------------------------------------
|
|
546
|
+
|
|
547
|
+
function resolverReply(
|
|
548
|
+
resourceName: string,
|
|
549
|
+
requestReply: RequestReplyDef,
|
|
550
|
+
message: GenesisMessage,
|
|
551
|
+
store: Store,
|
|
552
|
+
user: RequestingUser,
|
|
553
|
+
): Row {
|
|
554
|
+
// The resolver owns REQUEST interpretation entirely — no generic
|
|
555
|
+
// REQUEST/CRITERIA filtering is applied on top, since a computed reply's
|
|
556
|
+
// rows needn't echo the request's fields at all. Its rows go out exactly as
|
|
557
|
+
// returned.
|
|
558
|
+
const request: Row =
|
|
559
|
+
(typeof message.REQUEST === 'object' && message.REQUEST) || message.DETAILS?.REQUEST || {};
|
|
560
|
+
const rows = requestReply.resolver!({ request, message, user, store });
|
|
561
|
+
return {
|
|
562
|
+
MESSAGE_TYPE: `REP_${resourceName}`,
|
|
563
|
+
REPLY: applyResourceAuth(rows, requestReply.auth, user),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// A failed request is a MSG_NACK. A NackError keeps its items. Anything else
|
|
568
|
+
// is what each kind of server makes of an unexpected exception: a custom
|
|
569
|
+
// request server's GENERIC_ERROR with the message (AbstractCustomReqRep.kt), a
|
|
570
|
+
// plain one's REQUEST_FAILED with the message (RequestReply.kt
|
|
571
|
+
// handleThrowable), a criteria-only one's INTERNAL_ERROR.
|
|
572
|
+
function failureReply(error: unknown, requestReply: RequestReplyDef): Row {
|
|
573
|
+
if (error instanceof NackError) return serviceMsgNack(error.errors, error.warnings);
|
|
574
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
575
|
+
const code = requestReply.resolver
|
|
576
|
+
? ErrorCode.GENERIC_ERROR
|
|
577
|
+
: requestReply.criteriaOnly
|
|
578
|
+
? ErrorCode.INTERNAL_ERROR
|
|
579
|
+
: ErrorCode.REQUEST_FAILED;
|
|
580
|
+
const nack = NackError.standard(
|
|
581
|
+
code,
|
|
582
|
+
code === ErrorCode.INTERNAL_ERROR ? undefined : message || undefined,
|
|
583
|
+
);
|
|
584
|
+
return serviceMsgNack(nack.errors, nack.warnings);
|
|
23
585
|
}
|
|
24
586
|
|
|
25
587
|
export function handleRequestReply(
|
|
@@ -49,46 +611,68 @@ export function handleRequestReply(
|
|
|
49
611
|
return;
|
|
50
612
|
}
|
|
51
613
|
|
|
52
|
-
|
|
614
|
+
if (isLegacyFidelity(config)) {
|
|
615
|
+
legacyRequestReply(resourceName, requestReply, message, connection, store);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const user = requestingUser(connection);
|
|
620
|
+
let reply: Row;
|
|
621
|
+
try {
|
|
622
|
+
if (requestReply.resolver) {
|
|
623
|
+
reply = resolverReply(resourceName, requestReply, message, store, user);
|
|
624
|
+
} else if (requestReply.criteriaOnly) {
|
|
625
|
+
reply = criteriaOnlyReply(resourceName, requestReply, message, config, store, user);
|
|
626
|
+
} else {
|
|
627
|
+
reply = plainReply(resourceName, requestReply, message, config, store, user);
|
|
628
|
+
}
|
|
629
|
+
} catch (error) {
|
|
630
|
+
reply = failureReply(error, requestReply);
|
|
631
|
+
}
|
|
632
|
+
connection.reply(message, reply);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// --- fidelity: 'legacy' ------------------------------------------------------
|
|
636
|
+
// What this engine answered up to 15.47: REQUEST from the top level or
|
|
637
|
+
// DETAILS, '*', null and '' matching anything, an array value matching any of
|
|
638
|
+
// its values, CRITERIA_MATCH failing open, every row (no MAX_ROWS, OFFSET or
|
|
639
|
+
// ORDER_BY), no PARAMETRIC_TYPE or MORE_ROWS, rows without RECORD_ID and
|
|
640
|
+
// TIMESTAMP, and errors as a REP_ with ERROR.
|
|
641
|
+
|
|
642
|
+
function matchesLegacyRequestFields(row: Row, request: Row): boolean {
|
|
643
|
+
return Object.entries(request).every(([field, value]) => {
|
|
644
|
+
if (value === undefined || value === null || value === WILDCARD || value === '') return true;
|
|
645
|
+
if (Array.isArray(value)) return value.length === 0 || value.includes(row[field]);
|
|
646
|
+
return row[field] === value;
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function legacyRequestReply(
|
|
651
|
+
resourceName: string,
|
|
652
|
+
requestReply: RequestReplyDef,
|
|
653
|
+
message: GenesisMessage,
|
|
654
|
+
connection: Connection,
|
|
655
|
+
store: Store,
|
|
656
|
+
): void {
|
|
53
657
|
const user = requestingUser(connection);
|
|
54
658
|
const request: Row =
|
|
55
659
|
(typeof message.REQUEST === 'object' && message.REQUEST) || message.DETAILS?.REQUEST || {};
|
|
56
|
-
|
|
57
660
|
try {
|
|
58
661
|
let rows: Row[];
|
|
59
662
|
if (requestReply.resolver) {
|
|
60
|
-
// Computed reply (date calcs, holiday roll, composite lookups...): the
|
|
61
|
-
// resolver owns REQUEST interpretation entirely — no generic REQUEST/
|
|
62
|
-
// CRITERIA filtering is applied on top, since a computed reply's rows
|
|
63
|
-
// needn't echo the request's fields at all.
|
|
64
663
|
rows = requestReply.resolver({ request, message, user, store });
|
|
65
664
|
} else {
|
|
66
665
|
rows = store.getResourceRows(requestReply.source!);
|
|
67
666
|
if (Object.keys(request).length > 0) {
|
|
68
|
-
rows = rows.filter((row) =>
|
|
667
|
+
rows = rows.filter((row) => matchesLegacyRequestFields(row, request));
|
|
69
668
|
}
|
|
70
669
|
const criteria = message.DETAILS?.CRITERIA_MATCH ?? message.CRITERIA_MATCH;
|
|
71
670
|
if (criteria) rows = filterByCriteria(rows, criteria);
|
|
72
671
|
if (requestReply.filter) rows = rows.filter(requestReply.filter);
|
|
73
672
|
}
|
|
74
|
-
rows = applyResourceAuth(rows, requestReply.auth, user);
|
|
75
|
-
|
|
76
|
-
// a criteria-only request server's do on GSF. Deliberately on every
|
|
77
|
-
// source-backed server, criteriaOnly or not (README: a deviation until
|
|
78
|
-
// OFFSET paging, item 1.9): grid-pro's client-side REQUEST_SERVER
|
|
79
|
-
// datasource keys rows by RECORD_ID and gives up without it. A
|
|
80
|
-
// resolver's rows go out exactly as returned. Legacy mode sent none, a
|
|
81
|
-
// resolver's included (one returning stored rows would now have them).
|
|
82
|
-
if (legacy) rows = rows.map(withoutRecordFields);
|
|
83
|
-
|
|
84
|
-
connection.reply(message, {
|
|
85
|
-
MESSAGE_TYPE: `REP_${resourceName}`,
|
|
86
|
-
REPLY: rows,
|
|
87
|
-
});
|
|
673
|
+
rows = applyResourceAuth(rows, requestReply.auth, user).map(withoutRecordFields);
|
|
674
|
+
connection.reply(message, { MESSAGE_TYPE: `REP_${resourceName}`, REPLY: rows });
|
|
88
675
|
} catch (error) {
|
|
89
|
-
connection.reply(message, {
|
|
90
|
-
MESSAGE_TYPE: `REP_${resourceName}`,
|
|
91
|
-
...nackPayload(error, legacy),
|
|
92
|
-
});
|
|
676
|
+
connection.reply(message, { MESSAGE_TYPE: `REP_${resourceName}`, ...nackPayload(error, true) });
|
|
93
677
|
}
|
|
94
678
|
}
|