@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
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NackErrorInput } from './errors.ts';
|
|
2
|
+
type Schema = Record<string, any>;
|
|
3
|
+
export interface SchemaError {
|
|
4
|
+
keyword: string;
|
|
5
|
+
instanceLocation: string;
|
|
6
|
+
property?: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function validateSchema(schema: Schema, value: unknown, location?: string): SchemaError[];
|
|
10
|
+
export declare function schemaFieldErrors(errors: SchemaError[]): NackErrorInput[];
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Inbound schema validation, as GSF runs it on every event before any handler
|
|
2
|
+
// sees it: GenesisMessageDecoder validates the whole message against the
|
|
3
|
+
// event's JSON schema (the INBOUND that JSON_SCHEMA_REQUEST serves) and
|
|
4
|
+
// answers each failure with a FieldError VALIDATION_ERROR. GSF 8.15.29 uses
|
|
5
|
+
// networknt json-schema-validator 2.0.1, configured by
|
|
6
|
+
// GenesisJsonSchemaFactory (draft 2019-09, PathType.LEGACY,
|
|
7
|
+
// losslessNarrowing). This is the subset of that validator the engine's own
|
|
8
|
+
// event schemas need: type, const, enum, oneOf, properties, required,
|
|
9
|
+
// additionalProperties, minLength / maxLength, minimum / maximum and pattern.
|
|
10
|
+
// Texts, locations and their order follow networknt's output, checked by
|
|
11
|
+
// running the real validator on the recorded showcase schemas
|
|
12
|
+
// (test/schemaValidation.test.ts pins the cases).
|
|
13
|
+
//
|
|
14
|
+
// Order: at each schema, `type` and then `properties` are checked first and
|
|
15
|
+
// every other keyword after them in the schema's key order; `properties` in
|
|
16
|
+
// their key order; unknown properties in the instance's order. A failed
|
|
17
|
+
// `type` doesn't stop the rest (an enum still reports its own error).
|
|
18
|
+
// `oneOf` with no valid branch reports itself, then every branch's errors.
|
|
19
|
+
// networknt's JsonType names. With losslessNarrowing a number with no
|
|
20
|
+
// fraction is an integer (1.0 included, which JSON.parse makes 1 anyway).
|
|
21
|
+
function jsonTypeOf(value) {
|
|
22
|
+
if (value === null)
|
|
23
|
+
return 'null';
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return 'array';
|
|
26
|
+
if (typeof value === 'number')
|
|
27
|
+
return Number.isInteger(value) ? 'integer' : 'number';
|
|
28
|
+
return typeof value;
|
|
29
|
+
}
|
|
30
|
+
function matchesType(value, type) {
|
|
31
|
+
const actual = jsonTypeOf(value);
|
|
32
|
+
return actual === type || (type === 'number' && actual === 'integer');
|
|
33
|
+
}
|
|
34
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
function deepEqual(a, b) {
|
|
36
|
+
if (a === b)
|
|
37
|
+
return true;
|
|
38
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
39
|
+
return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
|
|
40
|
+
}
|
|
41
|
+
if (isObject(a) && isObject(b)) {
|
|
42
|
+
const keys = Object.keys(a);
|
|
43
|
+
return (keys.length === Object.keys(b).length &&
|
|
44
|
+
keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key])));
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
// networknt evaluates these before a schema's other keywords, whatever their
|
|
49
|
+
// position; the rest go in key order.
|
|
50
|
+
const FIRST_KEYWORDS = ['type', 'properties'];
|
|
51
|
+
// String lengths count code points, as Java's codePointCount does.
|
|
52
|
+
const codePoints = (text) => [...text].length;
|
|
53
|
+
const NOT_DEFINED = 'is not defined in the schema and the schema does not allow additional properties';
|
|
54
|
+
// One check per keyword, each a no-op on values it doesn't apply to (string
|
|
55
|
+
// keywords on non-strings, object keywords on non-objects, ...).
|
|
56
|
+
const KEYWORDS = {
|
|
57
|
+
type({ arg, value, fail }) {
|
|
58
|
+
if (typeof arg === 'string' && !matchesType(value, arg)) {
|
|
59
|
+
fail(`${jsonTypeOf(value)} found, ${arg} expected`);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
const({ arg, value, fail }) {
|
|
63
|
+
if (!deepEqual(value, arg))
|
|
64
|
+
fail(`must be the constant value '${String(arg)}'`);
|
|
65
|
+
},
|
|
66
|
+
enum({ arg, value, fail }) {
|
|
67
|
+
if (Array.isArray(arg) && !arg.some((candidate) => deepEqual(value, candidate))) {
|
|
68
|
+
const values = arg.map((item) => JSON.stringify(item)).join(', ');
|
|
69
|
+
fail(`does not have a value in the enumeration [${values}]`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
minLength({ arg, value, fail }) {
|
|
73
|
+
if (typeof value === 'string' && codePoints(value) < arg) {
|
|
74
|
+
fail(`must be at least ${arg} characters long`);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
maxLength({ arg, value, fail }) {
|
|
78
|
+
if (typeof value === 'string' && codePoints(value) > arg) {
|
|
79
|
+
fail(`must be at most ${arg} characters long`);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
minimum({ arg, value, fail }) {
|
|
83
|
+
if (typeof value === 'number' && value < arg)
|
|
84
|
+
fail(`must have a minimum value of ${arg}`);
|
|
85
|
+
},
|
|
86
|
+
maximum({ arg, value, fail }) {
|
|
87
|
+
if (typeof value === 'number' && value > arg)
|
|
88
|
+
fail(`must have a maximum value of ${arg}`);
|
|
89
|
+
},
|
|
90
|
+
// JSON Schema patterns are unanchored: '[0-9]+' accepts 'x1y'.
|
|
91
|
+
pattern({ arg, value, fail }) {
|
|
92
|
+
if (typeof value === 'string' && !new RegExp(arg, 'u').test(value)) {
|
|
93
|
+
fail(`does not match the regex pattern ${arg}`);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
oneOf({ arg, value, location, errors, fail }) {
|
|
97
|
+
if (!Array.isArray(arg))
|
|
98
|
+
return;
|
|
99
|
+
const results = arg.map((branch) => validateSchema(branch, value, location));
|
|
100
|
+
const valid = results.flatMap((result, index) => (result.length === 0 ? [index] : []));
|
|
101
|
+
if (valid.length === 1)
|
|
102
|
+
return;
|
|
103
|
+
if (valid.length > 1) {
|
|
104
|
+
fail(`must be valid to one and only one schema, but ${valid.length} are valid with indexes '${valid.join(', ')}'`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
fail('must be valid to one and only one schema, but 0 are valid');
|
|
108
|
+
errors.push(...results.flat());
|
|
109
|
+
},
|
|
110
|
+
properties({ arg, value, location, errors }) {
|
|
111
|
+
if (!isObject(value) || !isObject(arg))
|
|
112
|
+
return;
|
|
113
|
+
for (const [name, propertySchema] of Object.entries(arg)) {
|
|
114
|
+
if (!Object.hasOwn(value, name))
|
|
115
|
+
continue;
|
|
116
|
+
errors.push(...validateSchema(propertySchema, value[name], `${location}.${name}`));
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
required({ arg, value, fail }) {
|
|
120
|
+
if (!isObject(value) || !Array.isArray(arg))
|
|
121
|
+
return;
|
|
122
|
+
for (const name of arg) {
|
|
123
|
+
if (!Object.hasOwn(value, name))
|
|
124
|
+
fail(`required property '${name}' not found`, name);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
additionalProperties({ arg, schema, value, location, errors, fail }) {
|
|
128
|
+
if (!isObject(value) || (arg !== false && !isObject(arg)))
|
|
129
|
+
return;
|
|
130
|
+
const known = isObject(schema.properties) ? schema.properties : {};
|
|
131
|
+
for (const name of Object.keys(value)) {
|
|
132
|
+
if (Object.hasOwn(known, name))
|
|
133
|
+
continue;
|
|
134
|
+
if (arg === false)
|
|
135
|
+
fail(`property '${name}' ${NOT_DEFINED}`, name);
|
|
136
|
+
else
|
|
137
|
+
errors.push(...validateSchema(arg, value[name], `${location}.${name}`));
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
// Anything else is an annotation (description, title, default, readOnly,
|
|
141
|
+
// genesisType, $schema, $defs, ...) and validates nothing; networknt
|
|
142
|
+
// doesn't enforce readOnly unless asked to.
|
|
143
|
+
};
|
|
144
|
+
export function validateSchema(schema, value, location = '$') {
|
|
145
|
+
const errors = [];
|
|
146
|
+
if (!isObject(schema))
|
|
147
|
+
return errors;
|
|
148
|
+
const keywords = Object.keys(schema);
|
|
149
|
+
const ordered = [
|
|
150
|
+
...FIRST_KEYWORDS.filter((keyword) => keywords.includes(keyword)),
|
|
151
|
+
...keywords.filter((keyword) => !FIRST_KEYWORDS.includes(keyword)),
|
|
152
|
+
];
|
|
153
|
+
for (const keyword of ordered) {
|
|
154
|
+
if (!Object.hasOwn(KEYWORDS, keyword))
|
|
155
|
+
continue;
|
|
156
|
+
KEYWORDS[keyword]({
|
|
157
|
+
arg: schema[keyword],
|
|
158
|
+
schema,
|
|
159
|
+
value,
|
|
160
|
+
location,
|
|
161
|
+
errors,
|
|
162
|
+
fail: (message, property) => errors.push({
|
|
163
|
+
keyword,
|
|
164
|
+
instanceLocation: location,
|
|
165
|
+
...(property !== undefined ? { property } : {}),
|
|
166
|
+
message,
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return errors;
|
|
171
|
+
}
|
|
172
|
+
// GenesisMessageDecoder's FieldError for each failure: TEXT is the error's
|
|
173
|
+
// toString() ("<location>: <message>"), FIELD the missing property for
|
|
174
|
+
// `required` and otherwise the last segment of the location (so an unknown
|
|
175
|
+
// DETAILS property names 'DETAILS'), PATH the location.
|
|
176
|
+
export function schemaFieldErrors(errors) {
|
|
177
|
+
return errors.map((error) => ({
|
|
178
|
+
'@type': 'FieldError',
|
|
179
|
+
CODE: 'VALIDATION_ERROR',
|
|
180
|
+
TEXT: `${error.instanceLocation}: ${error.message}`,
|
|
181
|
+
FIELD: error.keyword === 'required'
|
|
182
|
+
? error.property
|
|
183
|
+
: error.instanceLocation.slice(error.instanceLocation.lastIndexOf('.') + 1),
|
|
184
|
+
PATH: error.instanceLocation,
|
|
185
|
+
}));
|
|
186
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
3
|
import { Store } from "./db/store.js";
|
|
4
|
+
import { crudEventHandler } from "./handlers/crudEvents.js";
|
|
4
5
|
import { pendingKey } from "./handlers/dataLogon.js";
|
|
5
6
|
import { createHttpRequestListener } from "./handlers/http.js";
|
|
6
7
|
import { permittedQueryRow, requestingUser } from "./handlers/resourceAuth.js";
|
|
@@ -306,8 +307,11 @@ export function createMockServer(config) {
|
|
|
306
307
|
function getRequestReply(resourceName) {
|
|
307
308
|
return config.requestReplies?.[resourceName];
|
|
308
309
|
}
|
|
310
|
+
// An eventHandlers entry, else the generic CRUD handler a table registers
|
|
311
|
+
// (TableDef.events) — so extendEventHandler can wrap a generic event; the
|
|
312
|
+
// wrapper is then an explicit handler, which wins.
|
|
309
313
|
function getEventHandler(eventName) {
|
|
310
|
-
return config.eventHandlers?.[eventName];
|
|
314
|
+
return config.eventHandlers?.[eventName] ?? crudEventHandler(eventName, config);
|
|
311
315
|
}
|
|
312
316
|
// augment(current) => replacement. `current` is undefined if nothing was
|
|
313
317
|
// registered under that name yet, so augment can also be used to define a
|
package/dist/types.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface GeneratedFieldDef {
|
|
|
26
26
|
kind: 'SEQUENCE' | 'AUTO_INCREMENT';
|
|
27
27
|
prefix?: string;
|
|
28
28
|
}
|
|
29
|
+
export type CrudEventVerb = 'INSERT' | 'MODIFY' | 'DELETE';
|
|
29
30
|
export interface IndexDef {
|
|
30
31
|
name: string;
|
|
31
32
|
fields: string[];
|
|
@@ -40,6 +41,7 @@ export interface TableDef {
|
|
|
40
41
|
fields?: FieldDef[];
|
|
41
42
|
generated?: GeneratedFieldDef[];
|
|
42
43
|
indexes?: IndexDef[];
|
|
44
|
+
events?: CrudEventVerb[];
|
|
43
45
|
}
|
|
44
46
|
export type SequenceFormat = 'prefix' | 'genesis';
|
|
45
47
|
export interface JoinDef {
|
|
@@ -94,6 +96,8 @@ export interface RequestReplyDef {
|
|
|
94
96
|
auth?: ResourceAuthDef;
|
|
95
97
|
criteriaOnly?: boolean;
|
|
96
98
|
requestFields?: string[];
|
|
99
|
+
fields?: string[];
|
|
100
|
+
rowLimit?: number;
|
|
97
101
|
}
|
|
98
102
|
export interface EventSchemaOverride {
|
|
99
103
|
table?: string;
|
|
@@ -138,6 +142,7 @@ export interface EventHandlerCtx {
|
|
|
138
142
|
message: GenesisMessage;
|
|
139
143
|
broadcast: BroadcastFn;
|
|
140
144
|
broadcastTableChange: BroadcastTableChangeFn;
|
|
145
|
+
validate: boolean;
|
|
141
146
|
}
|
|
142
147
|
export type EventHandlerResult = {
|
|
143
148
|
generated?: Row[];
|
package/package.json
CHANGED
package/src/db/criteria.ts
CHANGED
|
@@ -20,6 +20,19 @@ import type { Row } from '../types.ts';
|
|
|
20
20
|
|
|
21
21
|
export type Predicate = (record: Row) => boolean;
|
|
22
22
|
|
|
23
|
+
// A criteria this grammar can't parse: unbalanced parentheses, a missing
|
|
24
|
+
// operand, text it can't tokenize. GSF fails to compile those too.
|
|
25
|
+
export class CriteriaSyntaxError extends Error {
|
|
26
|
+
override name = 'CriteriaSyntaxError';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A well-formed call to a function EXPR_FUNCTIONS doesn't have. It may well be
|
|
30
|
+
// valid Groovy, so callers that refuse malformed criteria still fail open on
|
|
31
|
+
// this one.
|
|
32
|
+
export class UnsupportedCriteriaError extends Error {
|
|
33
|
+
override name = 'UnsupportedCriteriaError';
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
const TOKEN_RE =
|
|
24
37
|
/\s*(&&|\|\||<=|>=|!=|<>|==|=|<|>|!|\(|\)|,|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/g;
|
|
25
38
|
|
|
@@ -30,13 +43,13 @@ function tokenize(input: string): string[] {
|
|
|
30
43
|
TOKEN_RE.lastIndex = 0;
|
|
31
44
|
while ((match = TOKEN_RE.exec(input))) {
|
|
32
45
|
if (match.index !== lastIndex) {
|
|
33
|
-
throw new
|
|
46
|
+
throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
|
|
34
47
|
}
|
|
35
48
|
tokens.push(match[1]);
|
|
36
49
|
lastIndex = TOKEN_RE.lastIndex;
|
|
37
50
|
}
|
|
38
51
|
if (lastIndex !== input.length) {
|
|
39
|
-
throw new
|
|
52
|
+
throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
|
|
40
53
|
}
|
|
41
54
|
return tokens;
|
|
42
55
|
}
|
|
@@ -58,7 +71,7 @@ const COMPARATORS = new Set(['==', '=', '!=', '<>', '<', '<=', '>', '>=']);
|
|
|
58
71
|
// them makes every Expr.dateTimeIs* comparison silently false (date-filtered
|
|
59
72
|
// grids render zero rows with no warning). Row values may already be epoch
|
|
60
73
|
// millis (number) or an ISO string. Returns null if unparseable.
|
|
61
|
-
function parseGenesisDateTime(value: unknown): number | null {
|
|
74
|
+
export function parseGenesisDateTime(value: unknown): number | null {
|
|
62
75
|
if (typeof value === 'number') return value;
|
|
63
76
|
if (typeof value !== 'string') return null;
|
|
64
77
|
const match = value.match(/^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/);
|
|
@@ -250,13 +263,14 @@ class Parser {
|
|
|
250
263
|
if (this.peek() === '(') {
|
|
251
264
|
this.next();
|
|
252
265
|
const inner = this.parseExpression();
|
|
253
|
-
if (this.next() !== ')')
|
|
266
|
+
if (this.next() !== ')')
|
|
267
|
+
throw new CriteriaSyntaxError('Missing closing parenthesis in criteria');
|
|
254
268
|
return inner;
|
|
255
269
|
}
|
|
256
270
|
|
|
257
271
|
const leftToken = this.next();
|
|
258
272
|
if (leftToken === undefined) {
|
|
259
|
-
throw new
|
|
273
|
+
throw new CriteriaSyntaxError('Expected operand on the left side of comparison');
|
|
260
274
|
}
|
|
261
275
|
|
|
262
276
|
// Both function-call dialects arrive as one dotted token followed by '(':
|
|
@@ -268,12 +282,12 @@ class Parser {
|
|
|
268
282
|
|
|
269
283
|
const op = this.peek();
|
|
270
284
|
if (!op || !COMPARATORS.has(op)) {
|
|
271
|
-
throw new
|
|
285
|
+
throw new CriteriaSyntaxError(`Expected comparator, got: ${op}`);
|
|
272
286
|
}
|
|
273
287
|
this.next();
|
|
274
288
|
const rightToken = this.next();
|
|
275
289
|
if (rightToken === undefined) {
|
|
276
|
-
throw new
|
|
290
|
+
throw new CriteriaSyntaxError('Expected operand on the right side of comparison');
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
this.noteField(leftToken);
|
|
@@ -320,16 +334,19 @@ class Parser {
|
|
|
320
334
|
const args: string[] = [];
|
|
321
335
|
if (this.peek() !== ')') {
|
|
322
336
|
const arg = this.next();
|
|
323
|
-
if (arg === undefined)
|
|
337
|
+
if (arg === undefined)
|
|
338
|
+
throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
|
|
324
339
|
args.push(arg);
|
|
325
340
|
while (this.peek() === ',') {
|
|
326
341
|
this.next();
|
|
327
342
|
const nextArg = this.next();
|
|
328
|
-
if (nextArg === undefined)
|
|
343
|
+
if (nextArg === undefined)
|
|
344
|
+
throw new CriteriaSyntaxError(`Unterminated argument list in ${name}(...)`);
|
|
329
345
|
args.push(nextArg);
|
|
330
346
|
}
|
|
331
347
|
}
|
|
332
|
-
if (this.next() !== ')')
|
|
348
|
+
if (this.next() !== ')')
|
|
349
|
+
throw new CriteriaSyntaxError(`Missing closing parenthesis in ${name}(...)`);
|
|
333
350
|
|
|
334
351
|
// 'Expr.<fn>(field, value)' passes args through as-is; the method-call
|
|
335
352
|
// form '<FIELD>.<fn>(value)' makes the receiver field the implicit first
|
|
@@ -338,7 +355,7 @@ class Parser {
|
|
|
338
355
|
const receiver = name.slice(0, dotIndex);
|
|
339
356
|
const fnName = name.slice(dotIndex + 1);
|
|
340
357
|
const handler = EXPR_FUNCTIONS[fnName];
|
|
341
|
-
if (!handler) throw new
|
|
358
|
+
if (!handler) throw new UnsupportedCriteriaError(`Unsupported criteria function: ${name}`);
|
|
342
359
|
const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
|
|
343
360
|
for (const arg of allArgs) this.noteField(arg);
|
|
344
361
|
|
|
@@ -347,24 +364,37 @@ class Parser {
|
|
|
347
364
|
}
|
|
348
365
|
}
|
|
349
366
|
|
|
350
|
-
|
|
351
|
-
|
|
367
|
+
export interface ParsedCriteria {
|
|
368
|
+
predicate: Predicate;
|
|
369
|
+
// Every field the expression reads.
|
|
370
|
+
fields: string[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Parses a CRITERIA_MATCH without failing open: throws CriteriaSyntaxError for
|
|
374
|
+
// a malformed one and UnsupportedCriteriaError for a call to a function the
|
|
375
|
+
// grammar doesn't have. A blank criteria matches everything.
|
|
376
|
+
export function parseCriteria(criteria?: string | null): ParsedCriteria {
|
|
352
377
|
if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') {
|
|
353
|
-
return () => true;
|
|
378
|
+
return { predicate: () => true, fields: [] };
|
|
379
|
+
}
|
|
380
|
+
const tokens = tokenize(criteria.trim());
|
|
381
|
+
const parser = new Parser(tokens);
|
|
382
|
+
const predicate = parser.parseExpression();
|
|
383
|
+
if (parser.position !== tokens.length) {
|
|
384
|
+
throw new CriteriaSyntaxError(`Unexpected trailing tokens in criteria: ${criteria}`);
|
|
354
385
|
}
|
|
386
|
+
return { predicate, fields: [...parser.fields] };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
|
|
390
|
+
export function compileCriteria(criteria?: string | null): Predicate {
|
|
355
391
|
// Real Genesis clients can emit CRITERIA_MATCH syntax this grammar doesn't
|
|
356
392
|
// cover yet (e.g. function-call criteria like Expr.dateTimeIsGreaterEqual(...)
|
|
357
393
|
// from date-range filters). Failing open — matching everything and logging —
|
|
358
394
|
// keeps DATA_LOGON/broadcast working (unfiltered) instead of crashing the
|
|
359
395
|
// handler and leaving the client's grid stuck on "Loading...".
|
|
360
396
|
try {
|
|
361
|
-
|
|
362
|
-
const parser = new Parser(tokens);
|
|
363
|
-
const predicate = parser.parseExpression();
|
|
364
|
-
if (parser.position !== tokens.length) {
|
|
365
|
-
throw new Error(`Unexpected trailing tokens in criteria: ${criteria}`);
|
|
366
|
-
}
|
|
367
|
-
return predicate;
|
|
397
|
+
return parseCriteria(criteria).predicate;
|
|
368
398
|
} catch (error) {
|
|
369
399
|
console.warn(
|
|
370
400
|
`[mock-server] Unsupported CRITERIA_MATCH syntax, ignoring filter: ${criteria}`,
|
|
@@ -378,13 +408,8 @@ export function compileCriteria(criteria?: string | null): Predicate {
|
|
|
378
408
|
// (compileCriteria then fails open). The real dataserver refuses a criteria
|
|
379
409
|
// naming a field its query doesn't expose (INVALID_CRITERIA).
|
|
380
410
|
export function criteriaFields(criteria?: string | null): string[] | undefined {
|
|
381
|
-
if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') return [];
|
|
382
411
|
try {
|
|
383
|
-
|
|
384
|
-
const parser = new Parser(tokens);
|
|
385
|
-
parser.parseExpression();
|
|
386
|
-
if (parser.position !== tokens.length) return undefined;
|
|
387
|
-
return [...parser.fields];
|
|
412
|
+
return parseCriteria(criteria).fields;
|
|
388
413
|
} catch {
|
|
389
414
|
return undefined;
|
|
390
415
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Row ordering shared by the dataserver (DATA_LOGON ORDER_BY, an index name)
|
|
2
|
+
// and request servers (REQ_ DETAILS.ORDER_BY, fields with a direction).
|
|
3
|
+
|
|
4
|
+
import type { Row } from '../types.ts';
|
|
5
|
+
import { RECORD_ID } from './identity.ts';
|
|
6
|
+
|
|
7
|
+
// Nulls sort low (first ascending, last descending), as H2's default null
|
|
8
|
+
// ordering does; numbers numerically, booleans false before true, anything
|
|
9
|
+
// else by its string form (UTF-16 order, as Java's String.compareTo).
|
|
10
|
+
export function compareValues(a: unknown, b: unknown): number {
|
|
11
|
+
if (a === b) return 0;
|
|
12
|
+
if (a === undefined || a === null) return -1;
|
|
13
|
+
if (b === undefined || b === null) return 1;
|
|
14
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
15
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') return a ? 1 : -1;
|
|
16
|
+
const [left, right] = [String(a), String(b)];
|
|
17
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OrderedField {
|
|
21
|
+
field: string;
|
|
22
|
+
descending: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Sorts by the fields in turn, then by RECORD_ID ascending: the tie-breaker
|
|
26
|
+
// column GSF's SQL ordering always appends (GetByCriteriaOperation.kt,
|
|
27
|
+
// tieBreakerColumn = "RECORD_ID", v8.15.29), whatever the direction.
|
|
28
|
+
export function rowComparator(fields: OrderedField[]): (a: Row, b: Row) => number {
|
|
29
|
+
return (a, b) => {
|
|
30
|
+
for (const { field, descending } of fields) {
|
|
31
|
+
const result = compareValues(a[field], b[field]);
|
|
32
|
+
if (result !== 0) return descending ? -result : result;
|
|
33
|
+
}
|
|
34
|
+
return compareValues(a[RECORD_ID], b[RECORD_ID]);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ORDER_SEPARATOR = /\s*,\s*/;
|
|
39
|
+
const ORDER_FIELD = /^([A-Za-z]\w+)(?:\s+(ASC|DESC))?$/i;
|
|
40
|
+
|
|
41
|
+
// DETAILS.ORDER_BY as a request server reads it (OrderSpec.kt OrderedString,
|
|
42
|
+
// GSF v8.15.29): comma-separated fields, each with an optional ASC or DESC in
|
|
43
|
+
// any case. Throws GSF's IllegalArgumentException text for anything else,
|
|
44
|
+
// which the request server answers as REQUEST_FAILED.
|
|
45
|
+
export function parseOrderBy(orderBy: string): OrderedField[] {
|
|
46
|
+
return orderBy.split(ORDER_SEPARATOR).map((part) => {
|
|
47
|
+
const match = ORDER_FIELD.exec(part);
|
|
48
|
+
if (!match) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`${orderBy} is not a valid ORDER string, Fields should be comma separated, with followed ` +
|
|
51
|
+
"by an optional ASC or DESC. E.g. 'TRADER, TRADE_DATE DESC'",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return { field: match[1], descending: match[2]?.toUpperCase() === 'DESC' };
|
|
55
|
+
});
|
|
56
|
+
}
|