@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.
Files changed (48) hide show
  1. package/README.md +210 -27
  2. package/dist/db/criteria.d.ts +12 -0
  3. package/dist/db/criteria.js +39 -29
  4. package/dist/db/ordering.d.ts +8 -0
  5. package/dist/db/ordering.js +49 -0
  6. package/dist/db/schema.d.ts +17 -1
  7. package/dist/db/schema.js +131 -16
  8. package/dist/db/store.d.ts +8 -2
  9. package/dist/db/store.js +120 -44
  10. package/dist/handlers/commitEvent.js +32 -8
  11. package/dist/handlers/crudEvents.d.ts +2 -0
  12. package/dist/handlers/crudEvents.js +138 -0
  13. package/dist/handlers/dataLogon.js +2 -23
  14. package/dist/handlers/eventValidation.d.ts +5 -0
  15. package/dist/handlers/eventValidation.js +29 -0
  16. package/dist/handlers/jsonSchema.d.ts +3 -2
  17. package/dist/handlers/jsonSchema.js +23 -17
  18. package/dist/handlers/meta.js +13 -9
  19. package/dist/handlers/requestReply.js +518 -40
  20. package/dist/handlers/resources.js +3 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/protocol/fieldTypes.d.ts +1 -0
  23. package/dist/protocol/fieldTypes.js +6 -0
  24. package/dist/protocol/msgNack.d.ts +2 -0
  25. package/dist/protocol/msgNack.js +10 -0
  26. package/dist/protocol/schemaValidation.d.ts +11 -0
  27. package/dist/protocol/schemaValidation.js +186 -0
  28. package/dist/server.js +5 -1
  29. package/dist/types.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/db/criteria.ts +52 -27
  32. package/src/db/ordering.ts +56 -0
  33. package/src/db/schema.ts +164 -15
  34. package/src/db/store.ts +146 -46
  35. package/src/handlers/commitEvent.ts +39 -8
  36. package/src/handlers/crudEvents.ts +181 -0
  37. package/src/handlers/dataLogon.ts +2 -19
  38. package/src/handlers/eventValidation.ts +43 -0
  39. package/src/handlers/jsonSchema.ts +31 -19
  40. package/src/handlers/meta.ts +14 -9
  41. package/src/handlers/requestReply.ts +625 -41
  42. package/src/handlers/resources.ts +3 -0
  43. package/src/index.ts +1 -0
  44. package/src/protocol/fieldTypes.ts +7 -0
  45. package/src/protocol/msgNack.ts +12 -0
  46. package/src/protocol/schemaValidation.ts +208 -0
  47. package/src/server.ts +5 -1
  48. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genesislcap/mock-server",
3
- "version": "15.52.0",
3
+ "version": "15.53.0",
4
4
  "description": "Reusable in-memory Genesis WebSocket protocol mock server for client automation/CI, without a JVM or database.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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 Error(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
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 Error(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`);
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() !== ')') throw new Error('Missing closing parenthesis in criteria');
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 Error('Expected operand on the left side of comparison');
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 Error(`Expected comparator, got: ${op}`);
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 Error('Expected operand on the right side of comparison');
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) throw new Error(`Unterminated argument list in ${name}(...)`);
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) throw new Error(`Unterminated argument list in ${name}(...)`);
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() !== ')') throw new Error(`Missing closing parenthesis in ${name}(...)`);
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 Error(`Unsupported criteria function: ${name}`);
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
- /** Compiles a CRITERIA_MATCH string into a predicate function(record) => boolean. */
351
- export function compileCriteria(criteria?: string | null): Predicate {
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
- const tokens = tokenize(criteria.trim());
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
- const tokens = tokenize(criteria.trim());
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
+ }