@open-rap/runtime 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/abap/background.d.ts +14 -0
- package/dist/abap/background.js +130 -0
- package/dist/abap/background.js.map +1 -0
- package/dist/abap/bridge.d.ts +62 -0
- package/dist/abap/bridge.js +402 -0
- package/dist/abap/bridge.js.map +1 -0
- package/dist/abap/convert.d.ts +26 -0
- package/dist/abap/convert.js +197 -0
- package/dist/abap/convert.js.map +1 -0
- package/dist/abap/http.d.ts +34 -0
- package/dist/abap/http.js +133 -0
- package/dist/abap/http.js.map +1 -0
- package/dist/abap/mail.d.ts +10 -0
- package/dist/abap/mail.js +83 -0
- package/dist/abap/mail.js.map +1 -0
- package/dist/abap/odata-client.d.ts +33 -0
- package/dist/abap/odata-client.js +576 -0
- package/dist/abap/odata-client.js.map +1 -0
- package/dist/abap/sql.d.ts +92 -0
- package/dist/abap/sql.js +261 -0
- package/dist/abap/sql.js.map +1 -0
- package/dist/abap/utils.d.ts +41 -0
- package/dist/abap/utils.js +976 -0
- package/dist/abap/utils.js.map +1 -0
- package/dist/app.d.ts +53 -0
- package/dist/app.js +169 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/auth.d.ts +68 -0
- package/dist/auth/auth.js +140 -0
- package/dist/auth/auth.js.map +1 -0
- package/dist/auth/authorizations.d.ts +39 -0
- package/dist/auth/authorizations.js +103 -0
- package/dist/auth/authorizations.js.map +1 -0
- package/dist/auth/business-users.d.ts +15 -0
- package/dist/auth/business-users.js +36 -0
- package/dist/auth/business-users.js.map +1 -0
- package/dist/auth/dev-oauth.d.ts +38 -0
- package/dist/auth/dev-oauth.js +156 -0
- package/dist/auth/dev-oauth.js.map +1 -0
- package/dist/auth/environment.d.ts +7 -0
- package/dist/auth/environment.js +57 -0
- package/dist/auth/environment.js.map +1 -0
- package/dist/auth/session.d.ts +45 -0
- package/dist/auth/session.js +257 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/user.d.ts +21 -0
- package/dist/auth/user.js +12 -0
- package/dist/auth/user.js.map +1 -0
- package/dist/db/database.d.ts +30 -0
- package/dist/db/database.js +131 -0
- package/dist/db/database.js.map +1 -0
- package/dist/db/seed.d.ts +9 -0
- package/dist/db/seed.js +63 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/launchpad/dev.d.ts +14 -0
- package/dist/launchpad/dev.js +48 -0
- package/dist/launchpad/dev.js.map +1 -0
- package/dist/launchpad/proxy.d.ts +30 -0
- package/dist/launchpad/proxy.js +98 -0
- package/dist/launchpad/proxy.js.map +1 -0
- package/dist/launchpad/server.d.ts +77 -0
- package/dist/launchpad/server.js +177 -0
- package/dist/launchpad/server.js.map +1 -0
- package/dist/mcp/server.d.ts +22 -0
- package/dist/mcp/server.js +222 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/odata/access.d.ts +31 -0
- package/dist/odata/access.js +142 -0
- package/dist/odata/access.js.map +1 -0
- package/dist/odata/batch.d.ts +36 -0
- package/dist/odata/batch.js +147 -0
- package/dist/odata/batch.js.map +1 -0
- package/dist/odata/csrf.d.ts +13 -0
- package/dist/odata/csrf.js +27 -0
- package/dist/odata/csrf.js.map +1 -0
- package/dist/odata/custom.d.ts +90 -0
- package/dist/odata/custom.js +373 -0
- package/dist/odata/custom.js.map +1 -0
- package/dist/odata/draft.d.ts +78 -0
- package/dist/odata/draft.js +590 -0
- package/dist/odata/draft.js.map +1 -0
- package/dist/odata/errors.d.ts +27 -0
- package/dist/odata/errors.js +41 -0
- package/dist/odata/errors.js.map +1 -0
- package/dist/odata/handler.d.ts +90 -0
- package/dist/odata/handler.js +312 -0
- package/dist/odata/handler.js.map +1 -0
- package/dist/odata/json.d.ts +23 -0
- package/dist/odata/json.js +85 -0
- package/dist/odata/json.js.map +1 -0
- package/dist/odata/read.d.ts +90 -0
- package/dist/odata/read.js +686 -0
- package/dist/odata/read.js.map +1 -0
- package/dist/odata/service.d.ts +45 -0
- package/dist/odata/service.js +145 -0
- package/dist/odata/service.js.map +1 -0
- package/dist/odata/uri/ast.d.ts +103 -0
- package/dist/odata/uri/ast.js +3 -0
- package/dist/odata/uri/ast.js.map +1 -0
- package/dist/odata/uri/parser.d.ts +26 -0
- package/dist/odata/uri/parser.js +697 -0
- package/dist/odata/uri/parser.js.map +1 -0
- package/dist/odata/write.d.ts +127 -0
- package/dist/odata/write.js +799 -0
- package/dist/odata/write.js.map +1 -0
- package/dist/platform.d.ts +14 -0
- package/dist/platform.js +66 -0
- package/dist/platform.js.map +1 -0
- package/dist/rap/bo.d.ts +76 -0
- package/dist/rap/bo.js +184 -0
- package/dist/rap/bo.js.map +1 -0
- package/dist/rap/kernel.d.ts +7 -0
- package/dist/rap/kernel.js +287 -0
- package/dist/rap/kernel.js.map +1 -0
- package/dist/rap/runtime.d.ts +84 -0
- package/dist/rap/runtime.js +128 -0
- package/dist/rap/runtime.js.map +1 -0
- package/dist/rap/transaction.d.ts +227 -0
- package/dist/rap/transaction.js +1385 -0
- package/dist/rap/transaction.js.map +1 -0
- package/dist/rap/types.d.ts +74 -0
- package/dist/rap/types.js +25 -0
- package/dist/rap/types.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +114 -0
- package/dist/server.js.map +1 -0
- package/dist/ui/plugin.d.ts +14 -0
- package/dist/ui/plugin.js +47 -0
- package/dist/ui/plugin.js.map +1 -0
- package/dist/ui/resources.d.ts +22 -0
- package/dist/ui/resources.js +88 -0
- package/dist/ui/resources.js.map +1 -0
- package/dist/ui/variants.d.ts +25 -0
- package/dist/ui/variants.js +68 -0
- package/dist/ui/variants.js.map +1 -0
- package/dist/values.d.ts +43 -0
- package/dist/values.js +367 -0
- package/dist/values.js.map +1 -0
- package/package.json +60 -3
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads: OData query options → parameterized SQL on the views of the CDS entities (decision D4).
|
|
3
|
+
* Identifiers come from the model (white list), values are bound parameters.
|
|
4
|
+
*/
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
import { fromSql, literalToCanonical, toSql } from "../values.js";
|
|
7
|
+
import { badRequest, notImplemented, ODataError } from "./errors.js";
|
|
8
|
+
import { currentReader } from "./access.js";
|
|
9
|
+
import { DEPENDENT_ETAG } from "./json.js";
|
|
10
|
+
const MAX_PAGE_SIZE = 5000;
|
|
11
|
+
function escapeLike(value) {
|
|
12
|
+
return value.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
13
|
+
}
|
|
14
|
+
export class ReadEngine {
|
|
15
|
+
service;
|
|
16
|
+
db;
|
|
17
|
+
access;
|
|
18
|
+
/** The query classes of the custom entities (D43), when the application has a RAP runtime. */
|
|
19
|
+
queries;
|
|
20
|
+
/** Scopes on custom entities: the SQL placeholder stands for a condition given to the query class. */
|
|
21
|
+
customScopes = new WeakMap();
|
|
22
|
+
constructor(service, db,
|
|
23
|
+
/** The access controls (DCL) of the entities, for the user of the request (D35). */
|
|
24
|
+
access) {
|
|
25
|
+
this.service = service;
|
|
26
|
+
this.db = db;
|
|
27
|
+
this.access = access;
|
|
28
|
+
}
|
|
29
|
+
// -------------------------------------------------------------------------------------------
|
|
30
|
+
// custom entities (D43)
|
|
31
|
+
customScope(cond) {
|
|
32
|
+
const placeholder = sql `TRUE`;
|
|
33
|
+
this.customScopes.set(placeholder, cond);
|
|
34
|
+
return placeholder;
|
|
35
|
+
}
|
|
36
|
+
customQueries() {
|
|
37
|
+
if (this.queries === undefined) {
|
|
38
|
+
throw new ODataError(500, "NO_QUERY_RUNTIME", "custom entities are read by their query class: the application has no ABAP runtime");
|
|
39
|
+
}
|
|
40
|
+
return this.queries;
|
|
41
|
+
}
|
|
42
|
+
/** Equalities on properties, as a condition on the elements of the custom entity. */
|
|
43
|
+
equalities(info, properties, values) {
|
|
44
|
+
const items = properties.map((p, i) => ({ kind: "cmp", name: this.service.property(info, p).field.name, op: "eq", value: values[i] ?? null }));
|
|
45
|
+
return items.length === 1 ? items[0] : { kind: "and", items };
|
|
46
|
+
}
|
|
47
|
+
scopeConditions(scope) {
|
|
48
|
+
return scope.map((s) => {
|
|
49
|
+
const cond = this.customScopes.get(s);
|
|
50
|
+
if (cond === undefined) {
|
|
51
|
+
throw notImplemented("A condition in SQL on a custom entity");
|
|
52
|
+
}
|
|
53
|
+
return cond;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async customCollection(info, options, scope, withCount) {
|
|
57
|
+
const needed = this.neededForExpand(info, options.expand);
|
|
58
|
+
const properties = this.selectedProperties(info, options, needed);
|
|
59
|
+
const scoped = this.scopeConditions(scope);
|
|
60
|
+
const filter = options.filter === undefined ? undefined : this.customQueries().condition(info, options.filter);
|
|
61
|
+
const top = Math.min(options.top ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE);
|
|
62
|
+
const result = await this.customQueries().select(info, {
|
|
63
|
+
...(filter === undefined ? {} : { filter }),
|
|
64
|
+
scope: scoped,
|
|
65
|
+
...(options.orderby === undefined ? {} : { orderby: options.orderby }),
|
|
66
|
+
...(options.search === undefined ? {} : { search: options.search }),
|
|
67
|
+
top,
|
|
68
|
+
skip: options.skip ?? 0,
|
|
69
|
+
count: withCount,
|
|
70
|
+
data: true,
|
|
71
|
+
properties,
|
|
72
|
+
});
|
|
73
|
+
const rows = result.rows;
|
|
74
|
+
const read = { rows };
|
|
75
|
+
if (withCount && result.total !== undefined) {
|
|
76
|
+
read.count = result.total;
|
|
77
|
+
}
|
|
78
|
+
await this.expand(info, rows, options.expand);
|
|
79
|
+
return read;
|
|
80
|
+
}
|
|
81
|
+
async customExpand(rows, target, navigation, item, properties, needed) {
|
|
82
|
+
const targetFields = navigation.on.map((p) => p.target);
|
|
83
|
+
const tuples = rows.map((r) => navigation.on.map((p) => r[p.source]));
|
|
84
|
+
const distinct = [...new Map(tuples.map((t) => [JSON.stringify(t), t])).values()].filter((t) => t.every((v) => v !== null));
|
|
85
|
+
let matches = [];
|
|
86
|
+
if (distinct.length > 0) {
|
|
87
|
+
const scope = distinct.length === 1 ? this.equalities(target, targetFields, distinct[0] ?? []) : { kind: "or", items: distinct.map((t) => this.equalities(target, targetFields, t)) };
|
|
88
|
+
const filter = item.options.filter === undefined ? undefined : this.customQueries().condition(target, item.options.filter);
|
|
89
|
+
const result = await this.customQueries().select(target, {
|
|
90
|
+
...(filter === undefined ? {} : { filter }),
|
|
91
|
+
scope: [scope],
|
|
92
|
+
...(item.options.orderby === undefined ? {} : { orderby: item.options.orderby }),
|
|
93
|
+
count: false,
|
|
94
|
+
data: true,
|
|
95
|
+
properties,
|
|
96
|
+
});
|
|
97
|
+
matches = result.rows;
|
|
98
|
+
}
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
const all = matches.filter((m) => navigation.on.every((p) => m[p.target] === row[p.source]));
|
|
101
|
+
const skip = item.options.skip ?? 0;
|
|
102
|
+
const mine = all.slice(skip, item.options.top === undefined ? undefined : skip + item.options.top);
|
|
103
|
+
row[navigation.name] = navigation.collection ? mine.map((m) => strip(m, properties, needed)) : mine[0] === undefined ? null : strip(mine[0], properties, needed);
|
|
104
|
+
if (navigation.collection && item.options.count === true) {
|
|
105
|
+
row[`${navigation.name}@odata.count`] = all.length;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// -------------------------------------------------------------------------------------------
|
|
110
|
+
// expressions
|
|
111
|
+
column(info, alias, path) {
|
|
112
|
+
if (path.length === 1) {
|
|
113
|
+
const name = path[0];
|
|
114
|
+
const { property, field } = this.service.property(info, name);
|
|
115
|
+
return { sql: sql.ref(`${alias}.${property.name}`), abap: field.type, edm: property.edm };
|
|
116
|
+
}
|
|
117
|
+
if (path.length === 2) {
|
|
118
|
+
// a to-one navigation: scalar sub-query on the target view
|
|
119
|
+
const { navigation, target } = this.service.navigationTarget(info, path[0]);
|
|
120
|
+
if (navigation.collection) {
|
|
121
|
+
throw notImplemented(`The collection navigation ${navigation.name} in an expression (use any/all)`);
|
|
122
|
+
}
|
|
123
|
+
const { property, field } = this.service.property(target, path[1]);
|
|
124
|
+
const inner = `${alias}_${navigation.name.replace(/\W/g, "")}`;
|
|
125
|
+
const conditions = navigation.on.map((p) => sql `${sql.ref(`${inner}.${p.target}`)} = ${sql.ref(`${alias}.${p.source}`)}`);
|
|
126
|
+
return {
|
|
127
|
+
sql: sql `(SELECT ${sql.ref(`${inner}.${property.name}`)} FROM ${sql.table(target.view)} AS ${sql.id(inner)} WHERE ${sql.join(conditions, sql ` AND `)} LIMIT 1)`,
|
|
128
|
+
abap: field.type,
|
|
129
|
+
edm: property.edm,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
throw notImplemented(`The path ${path.join("/")}`);
|
|
133
|
+
}
|
|
134
|
+
operand(info, alias, expr) {
|
|
135
|
+
switch (expr.kind) {
|
|
136
|
+
case "member":
|
|
137
|
+
return this.column(info, alias, expr.path);
|
|
138
|
+
case "literal":
|
|
139
|
+
return { sql: sql `${expr.literal.value}`, literal: expr.literal };
|
|
140
|
+
case "call":
|
|
141
|
+
return this.call(info, alias, expr.name, expr.args);
|
|
142
|
+
default:
|
|
143
|
+
return { sql: this.condition(info, alias, expr), edm: { type: "Edm.Boolean" } };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** A literal bound to the type of the other side of a comparison. */
|
|
147
|
+
bind(side, other, name) {
|
|
148
|
+
if (side.literal === undefined) {
|
|
149
|
+
return side.sql;
|
|
150
|
+
}
|
|
151
|
+
if (other.abap !== undefined && other.edm !== undefined) {
|
|
152
|
+
const canonical = literalToCanonical(other.abap, other.edm, side.literal, name);
|
|
153
|
+
return sql `${toSql(other.abap, canonical)}`;
|
|
154
|
+
}
|
|
155
|
+
if (other.edm?.type === "Edm.Int32") {
|
|
156
|
+
return sql `${Number(side.literal.value)}`;
|
|
157
|
+
}
|
|
158
|
+
return sql `${side.literal.value}`;
|
|
159
|
+
}
|
|
160
|
+
stringArgument(info, alias, expr, fn) {
|
|
161
|
+
if (expr === undefined) {
|
|
162
|
+
throw badRequest(`${fn}: missing argument`);
|
|
163
|
+
}
|
|
164
|
+
const typed = this.operand(info, alias, expr);
|
|
165
|
+
if (typed.literal !== undefined) {
|
|
166
|
+
if (typed.literal.type !== "string") {
|
|
167
|
+
throw badRequest(`${fn}: the argument must be a string`);
|
|
168
|
+
}
|
|
169
|
+
return sql `${typed.literal.value}`;
|
|
170
|
+
}
|
|
171
|
+
return typed.edm?.type === "Edm.String" ? typed.sql : sql `CAST(${typed.sql} AS text)`;
|
|
172
|
+
}
|
|
173
|
+
likePattern(info, alias, expr, fn, prefix, suffix) {
|
|
174
|
+
if (expr?.kind !== "literal" || expr.literal.type !== "string") {
|
|
175
|
+
const value = this.stringArgument(info, alias, expr, fn);
|
|
176
|
+
return sql `${prefix} || replace(replace(replace(${value}, '\\', '\\\\'), '%', '\\%'), '_', '\\_') || ${suffix}`;
|
|
177
|
+
}
|
|
178
|
+
return sql `${`${prefix}${escapeLike(String(expr.literal.value))}${suffix}`}`;
|
|
179
|
+
}
|
|
180
|
+
call(info, alias, name, args) {
|
|
181
|
+
const string = (i) => this.stringArgument(info, alias, args[i], name);
|
|
182
|
+
const any = (i) => {
|
|
183
|
+
const arg = args[i];
|
|
184
|
+
if (arg === undefined) {
|
|
185
|
+
throw badRequest(`${name}: missing argument ${i + 1}`);
|
|
186
|
+
}
|
|
187
|
+
return this.operand(info, alias, arg);
|
|
188
|
+
};
|
|
189
|
+
const int = { type: "Edm.Int32" };
|
|
190
|
+
switch (name) {
|
|
191
|
+
case "contains":
|
|
192
|
+
return { sql: sql `(${string(0)} LIKE ${this.likePattern(info, alias, args[1], name, "%", "%")} ESCAPE '\\')`, edm: { type: "Edm.Boolean" } };
|
|
193
|
+
case "startswith":
|
|
194
|
+
return { sql: sql `(${string(0)} LIKE ${this.likePattern(info, alias, args[1], name, "", "%")} ESCAPE '\\')`, edm: { type: "Edm.Boolean" } };
|
|
195
|
+
case "endswith":
|
|
196
|
+
return { sql: sql `(${string(0)} LIKE ${this.likePattern(info, alias, args[1], name, "%", "")} ESCAPE '\\')`, edm: { type: "Edm.Boolean" } };
|
|
197
|
+
case "tolower":
|
|
198
|
+
return { sql: sql `lower(${string(0)})`, edm: { type: "Edm.String" } };
|
|
199
|
+
case "toupper":
|
|
200
|
+
return { sql: sql `upper(${string(0)})`, edm: { type: "Edm.String" } };
|
|
201
|
+
case "trim":
|
|
202
|
+
return { sql: sql `btrim(${string(0)})`, edm: { type: "Edm.String" } };
|
|
203
|
+
case "length":
|
|
204
|
+
return { sql: sql `char_length(${string(0)})`, edm: int };
|
|
205
|
+
case "indexof":
|
|
206
|
+
return { sql: sql `(strpos(${string(0)}, ${string(1)}) - 1)`, edm: int };
|
|
207
|
+
case "concat":
|
|
208
|
+
return { sql: sql `(${string(0)} || ${string(1)})`, edm: { type: "Edm.String" } };
|
|
209
|
+
case "substring": {
|
|
210
|
+
const start = any(1);
|
|
211
|
+
const length = args[2] === undefined ? undefined : any(2);
|
|
212
|
+
return {
|
|
213
|
+
sql: length === undefined ? sql `substr(${string(0)}, ${this.bind(start, { sql: sql ``, edm: int }, name)} + 1)` : sql `substr(${string(0)}, ${this.bind(start, { sql: sql ``, edm: int }, name)} + 1, ${this.bind(length, { sql: sql ``, edm: int }, name)})`,
|
|
214
|
+
edm: { type: "Edm.String" },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
case "year":
|
|
218
|
+
case "month":
|
|
219
|
+
case "day":
|
|
220
|
+
case "hour":
|
|
221
|
+
case "minute":
|
|
222
|
+
case "second": {
|
|
223
|
+
const value = any(0);
|
|
224
|
+
return { sql: sql `CAST(EXTRACT(${sql.raw(name.toUpperCase())} FROM ${value.sql}) AS integer)`, edm: int };
|
|
225
|
+
}
|
|
226
|
+
case "now":
|
|
227
|
+
return { sql: sql `now()`, edm: { type: "Edm.DateTimeOffset", precision: 7 }, abap: { kind: "timestampl" } };
|
|
228
|
+
default:
|
|
229
|
+
throw notImplemented(`The function ${name}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
condition(info, alias, expr) {
|
|
233
|
+
switch (expr.kind) {
|
|
234
|
+
case "binary": {
|
|
235
|
+
if (expr.op === "and" || expr.op === "or") {
|
|
236
|
+
const left = this.condition(info, alias, expr.left);
|
|
237
|
+
const right = this.condition(info, alias, expr.right);
|
|
238
|
+
return expr.op === "and" ? sql `(${left} AND ${right})` : sql `(${left} OR ${right})`;
|
|
239
|
+
}
|
|
240
|
+
const left = this.operand(info, alias, expr.left);
|
|
241
|
+
const right = this.operand(info, alias, expr.right);
|
|
242
|
+
const name = expr.left.kind === "member" ? expr.left.path.join("/") : expr.right.kind === "member" ? expr.right.path.join("/") : "";
|
|
243
|
+
if (right.literal?.type === "null" || left.literal?.type === "null") {
|
|
244
|
+
const other = right.literal?.type === "null" ? left : right;
|
|
245
|
+
if (expr.op === "eq")
|
|
246
|
+
return sql `(${other.sql} IS NULL)`;
|
|
247
|
+
if (expr.op === "ne")
|
|
248
|
+
return sql `(${other.sql} IS NOT NULL)`;
|
|
249
|
+
throw badRequest(`null can only be compared with eq or ne`);
|
|
250
|
+
}
|
|
251
|
+
const l = this.bind(left, right, name);
|
|
252
|
+
const r = this.bind(right, left, name);
|
|
253
|
+
const op = { eq: "=", ne: "<>", gt: ">", ge: ">=", lt: "<", le: "<=" }[expr.op];
|
|
254
|
+
if (expr.op === "ne") {
|
|
255
|
+
// OData: ne is true when the property is null and the literal is not
|
|
256
|
+
return sql `(${l} IS DISTINCT FROM ${r})`;
|
|
257
|
+
}
|
|
258
|
+
return sql `(${l} ${sql.raw(op)} ${r})`;
|
|
259
|
+
}
|
|
260
|
+
case "not":
|
|
261
|
+
return sql `(NOT ${this.condition(info, alias, expr.operand)})`;
|
|
262
|
+
case "in": {
|
|
263
|
+
const operand = this.operand(info, alias, expr.operand);
|
|
264
|
+
const values = expr.list.map((item) => this.bind(this.operand(info, alias, item), operand, ""));
|
|
265
|
+
return values.length === 0 ? sql `FALSE` : sql `(${operand.sql} IN (${sql.join(values)}))`;
|
|
266
|
+
}
|
|
267
|
+
case "call": {
|
|
268
|
+
const typed = this.call(info, alias, expr.name, expr.args);
|
|
269
|
+
if (typed.edm?.type !== "Edm.Boolean") {
|
|
270
|
+
throw badRequest(`${expr.name} does not return a boolean`);
|
|
271
|
+
}
|
|
272
|
+
return typed.sql;
|
|
273
|
+
}
|
|
274
|
+
case "member": {
|
|
275
|
+
const typed = this.column(info, alias, expr.path);
|
|
276
|
+
if (typed.edm?.type !== "Edm.Boolean") {
|
|
277
|
+
throw badRequest(`${expr.path.join("/")} is not a boolean`);
|
|
278
|
+
}
|
|
279
|
+
return sql `(${typed.sql} = TRUE)`;
|
|
280
|
+
}
|
|
281
|
+
case "literal":
|
|
282
|
+
if (expr.literal.type === "bool") {
|
|
283
|
+
return expr.literal.value === true ? sql `TRUE` : sql `FALSE`;
|
|
284
|
+
}
|
|
285
|
+
throw badRequest("A literal is not a condition");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** `$search`: every term must be found in one of the search fields (case-insensitive). */
|
|
289
|
+
search(info, alias, text) {
|
|
290
|
+
const fields = info.set.searchFields.length > 0 ? info.set.searchFields : [];
|
|
291
|
+
if (!info.set.searchable || fields.length === 0) {
|
|
292
|
+
throw badRequest(`${info.set.name} does not support $search`);
|
|
293
|
+
}
|
|
294
|
+
const terms = (text.match(/"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^"|"$/g, "")).filter((t) => t !== "" && t !== "AND" && t !== "OR");
|
|
295
|
+
if (terms.length === 0) {
|
|
296
|
+
return sql `TRUE`;
|
|
297
|
+
}
|
|
298
|
+
const perTerm = terms.map((term) => {
|
|
299
|
+
const pattern = `%${escapeLike(term)}%`;
|
|
300
|
+
return sql `(${sql.join(fields.map((f) => sql `CAST(${sql.ref(`${alias}.${f}`)} AS text) ILIKE ${pattern} ESCAPE '\\'`), sql ` OR `)})`;
|
|
301
|
+
});
|
|
302
|
+
return sql.join(perTerm, sql ` AND `);
|
|
303
|
+
}
|
|
304
|
+
// -------------------------------------------------------------------------------------------
|
|
305
|
+
// queries
|
|
306
|
+
/** The condition of the access controls of the entity for the user of the request, if any. */
|
|
307
|
+
accessCondition(info, alias) {
|
|
308
|
+
const fieldOf = (entity, name) => {
|
|
309
|
+
const field = entity.fields.find((f) => f.name === name) ?? entity.fields.find((f) => f.name.toLowerCase() === name.toLowerCase());
|
|
310
|
+
if (field === undefined) {
|
|
311
|
+
throw new Error(`access control: ${entity.name} has no element ${name}`);
|
|
312
|
+
}
|
|
313
|
+
return field;
|
|
314
|
+
};
|
|
315
|
+
return this.access?.condition(info.entity.name, (element) => {
|
|
316
|
+
const [association, name] = element.split(".");
|
|
317
|
+
if (name === undefined) {
|
|
318
|
+
const field = fieldOf(info.entity, element);
|
|
319
|
+
return { sql: sql.ref(`${alias}.${field.name}`), type: field.type };
|
|
320
|
+
}
|
|
321
|
+
// a path through a to-one association (_Travel.AgencyID): scalar sub-query on the target view
|
|
322
|
+
const { navigation, target } = this.service.navigationTarget(info, association);
|
|
323
|
+
const field = fieldOf(target.entity, name);
|
|
324
|
+
const inner = `${alias}_dcl_${association.replace(/\W/g, "")}`;
|
|
325
|
+
const conditions = navigation.on.map((p) => sql `${sql.ref(`${inner}.${p.target}`)} = ${sql.ref(`${alias}.${p.source}`)}`);
|
|
326
|
+
return {
|
|
327
|
+
sql: sql `(SELECT ${sql.ref(`${inner}.${field.name}`)} FROM ${sql.table(target.view)} AS ${sql.id(inner)} WHERE ${sql.join(conditions, sql ` AND `)} LIMIT 1)`,
|
|
328
|
+
type: field.type,
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
where(info, alias, options, extra) {
|
|
333
|
+
const conditions = [...extra];
|
|
334
|
+
// drafts (D54): the active instances, and the drafts in process by the user
|
|
335
|
+
if (info.entity.draft !== undefined && !("admin" in info.entity.draft)) {
|
|
336
|
+
const user = currentReader()?.abapName ?? "";
|
|
337
|
+
conditions.push(sql `(${sql.ref(`${alias}.IsActiveEntity`)} OR EXISTS (SELECT 1 FROM "rapt_draft_admin" "dra" WHERE "dra"."DraftUUID" = ${sql.ref(`${alias}.DraftUUID`)} AND ("dra"."InProcessByUser" = ${user} OR EXISTS (SELECT 1 FROM "rapt_draft_user" "dru" WHERE "dru"."DraftUUID" = "dra"."DraftUUID" AND "dru"."UserID" = ${user}))))`);
|
|
338
|
+
}
|
|
339
|
+
const access = this.accessCondition(info, alias);
|
|
340
|
+
if (access !== undefined) {
|
|
341
|
+
conditions.push(access);
|
|
342
|
+
}
|
|
343
|
+
if (options.filter !== undefined) {
|
|
344
|
+
conditions.push(this.condition(info, alias, options.filter));
|
|
345
|
+
}
|
|
346
|
+
if (options.search !== undefined && options.search.trim() !== "") {
|
|
347
|
+
conditions.push(this.search(info, alias, options.search));
|
|
348
|
+
}
|
|
349
|
+
return conditions.length === 0 ? sql `` : sql `WHERE ${sql.join(conditions, sql ` AND `)}`;
|
|
350
|
+
}
|
|
351
|
+
orderBy(info, alias, orderby) {
|
|
352
|
+
const items = (orderby ?? []).map((o) => {
|
|
353
|
+
const column = this.column(info, alias, o.path);
|
|
354
|
+
return o.descending ? sql `${column.sql} DESC NULLS LAST` : sql `${column.sql} ASC NULLS FIRST`;
|
|
355
|
+
});
|
|
356
|
+
// the keys make the order total, so that paging is stable
|
|
357
|
+
for (const key of info.type.keys) {
|
|
358
|
+
items.push(sql `${sql.ref(`${alias}.${key}`)} ASC`);
|
|
359
|
+
}
|
|
360
|
+
return sql `ORDER BY ${sql.join(items)}`;
|
|
361
|
+
}
|
|
362
|
+
/** Properties to read: the selected ones, the keys, the ETag and the fields joins need. */
|
|
363
|
+
selectedProperties(info, options, needed = []) {
|
|
364
|
+
const all = [...info.properties.keys()];
|
|
365
|
+
let selected;
|
|
366
|
+
if (options.select === undefined || options.select.some((p) => p[0] === "*")) {
|
|
367
|
+
selected = all;
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
selected = [];
|
|
371
|
+
for (const path of options.select) {
|
|
372
|
+
const name = path[0];
|
|
373
|
+
if (name.startsWith("__")) {
|
|
374
|
+
continue; // instance feature control (also its members: __OperationControl/accept), computed by the RAP runtime
|
|
375
|
+
}
|
|
376
|
+
if (path.length > 1 || name.includes(".")) {
|
|
377
|
+
if (name.includes(".")) {
|
|
378
|
+
continue; // operations in $select (advertised actions) carry no data
|
|
379
|
+
}
|
|
380
|
+
throw notImplemented(`$select of the path ${path.join("/")}`);
|
|
381
|
+
}
|
|
382
|
+
if (!info.properties.has(name)) {
|
|
383
|
+
if (info.navigations.has(name)) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
throw badRequest(`$select: ${info.type.name} has no property ${name}`, name);
|
|
387
|
+
}
|
|
388
|
+
selected.push(name);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const result = new Set([...info.type.keys, ...selected, ...needed]);
|
|
392
|
+
if (info.type.etag !== undefined) {
|
|
393
|
+
result.add(info.type.etag);
|
|
394
|
+
}
|
|
395
|
+
return all.filter((p) => result.has(p));
|
|
396
|
+
}
|
|
397
|
+
rowFrom(info, raw, properties) {
|
|
398
|
+
const row = {};
|
|
399
|
+
for (const name of properties) {
|
|
400
|
+
const { field } = this.service.property(info, name);
|
|
401
|
+
row[name] = fromSql(field.type, raw[name]);
|
|
402
|
+
}
|
|
403
|
+
const etag = this.dependentEtag(info, "t0");
|
|
404
|
+
if (etag !== undefined) {
|
|
405
|
+
row[DEPENDENT_ETAG] = fromSql(etag.type, raw[DEPENDENT_ETAG]);
|
|
406
|
+
}
|
|
407
|
+
return row;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The ETag of an `etag dependent` entity (D32): the ETag field of the master instance, read by
|
|
411
|
+
* scalar sub-queries along the to-one navigations of the path.
|
|
412
|
+
*/
|
|
413
|
+
dependentEtag(info, alias) {
|
|
414
|
+
const path = info.type.etagPath;
|
|
415
|
+
if (path === undefined) {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
const hops = [];
|
|
419
|
+
let current = info;
|
|
420
|
+
let outer = alias;
|
|
421
|
+
path.navigations.forEach((name, i) => {
|
|
422
|
+
const { navigation, target } = this.service.navigationTarget(current, name);
|
|
423
|
+
const inner = `${alias}_etag${i}`;
|
|
424
|
+
hops.push({ on: navigation.on, view: target.view, inner, outer });
|
|
425
|
+
current = target;
|
|
426
|
+
outer = inner;
|
|
427
|
+
});
|
|
428
|
+
const { property, field } = this.service.property(current, path.property);
|
|
429
|
+
let expression = sql.ref(`${outer}.${property.name}`);
|
|
430
|
+
for (const hop of [...hops].reverse()) {
|
|
431
|
+
const conditions = hop.on.map((p) => sql `${sql.ref(`${hop.inner}.${p.target}`)} = ${sql.ref(`${hop.outer}.${p.source}`)}`);
|
|
432
|
+
expression = sql `(SELECT ${expression} FROM ${sql.table(hop.view)} AS ${sql.id(hop.inner)} WHERE ${sql.join(conditions, sql ` AND `)} LIMIT 1)`;
|
|
433
|
+
}
|
|
434
|
+
return { sql: expression, type: field.type };
|
|
435
|
+
}
|
|
436
|
+
/** The columns of a query: the properties, and the ETag of an `etag dependent` entity. */
|
|
437
|
+
columns(info, alias, properties) {
|
|
438
|
+
const columns = properties.map((p) => sql.ref(`${alias}.${p}`));
|
|
439
|
+
const etag = this.dependentEtag(info, alias);
|
|
440
|
+
if (etag !== undefined) {
|
|
441
|
+
columns.push(sql `${etag.sql} AS ${sql.id(DEPENDENT_ETAG)}`);
|
|
442
|
+
}
|
|
443
|
+
return sql.join(columns);
|
|
444
|
+
}
|
|
445
|
+
/** Conditions selecting one instance on the alias t0. */
|
|
446
|
+
keyConditions(info, key) {
|
|
447
|
+
return this.keyCondition(info, "t0", key);
|
|
448
|
+
}
|
|
449
|
+
keyCondition(info, alias, key) {
|
|
450
|
+
if (info.entity.kind === "custom") {
|
|
451
|
+
return [this.customScope(this.equalities(info, info.type.keys, info.type.keys.map((k) => key[k] ?? null)))];
|
|
452
|
+
}
|
|
453
|
+
return info.type.keys.map((k) => {
|
|
454
|
+
const { field } = this.service.property(info, k);
|
|
455
|
+
return sql `${sql.ref(`${alias}.${k}`)} = ${toSql(field.type, key[k] ?? null)}`;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/** Reads a collection of an entity set, optionally restricted to the targets of a navigation. */
|
|
459
|
+
async readCollection(info, options, scope = [], withCount = options.count === true) {
|
|
460
|
+
if (info.entity.kind === "custom") {
|
|
461
|
+
return this.customCollection(info, options, scope, withCount);
|
|
462
|
+
}
|
|
463
|
+
const alias = "t0";
|
|
464
|
+
const needed = this.neededForExpand(info, options.expand);
|
|
465
|
+
const properties = this.selectedProperties(info, options, needed);
|
|
466
|
+
const columns = this.columns(info, alias, properties);
|
|
467
|
+
const where = this.where(info, alias, options, scope);
|
|
468
|
+
const limit = Math.min(options.top ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE);
|
|
469
|
+
const query = sql `SELECT ${columns} FROM ${sql.table(info.view)} AS ${sql.id(alias)} ${where} ${this.orderBy(info, alias, options.orderby)} LIMIT ${limit} OFFSET ${options.skip ?? 0}`;
|
|
470
|
+
const { rows } = await query.execute(this.db());
|
|
471
|
+
const result = { rows: rows.map((r) => this.rowFrom(info, r, properties)) };
|
|
472
|
+
if (withCount) {
|
|
473
|
+
result.count = await this.count(info, options, scope);
|
|
474
|
+
}
|
|
475
|
+
await this.expand(info, result.rows, options.expand);
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* `$apply` (D52): the groups of the dimensions with their aggregates, after the filters of
|
|
480
|
+
* `$apply` and `$filter` (on the instances), with the access controls; `$orderby` on the
|
|
481
|
+
* dimensions and the aliases, `$top`, `$skip`, `$count` (number of groups).
|
|
482
|
+
*/
|
|
483
|
+
async readAggregated(info, options, scope = []) {
|
|
484
|
+
if (info.entity.kind === "custom") {
|
|
485
|
+
throw notImplemented("$apply on a custom entity");
|
|
486
|
+
}
|
|
487
|
+
const steps = options.apply ?? [];
|
|
488
|
+
const alias = "t0";
|
|
489
|
+
const filters = steps.filter((s) => s.kind === "filter").map((s) => this.condition(info, alias, s.filter));
|
|
490
|
+
const grouping = steps.find((s) => s.kind !== "filter");
|
|
491
|
+
const dimensions = grouping?.kind === "groupby" ? grouping.properties : [];
|
|
492
|
+
const items = grouping === undefined ? [] : grouping.aggregate;
|
|
493
|
+
for (const d of dimensions) {
|
|
494
|
+
if (!info.properties.has(d)) {
|
|
495
|
+
throw badRequest(`$apply: ${info.type.name} has no property ${d}`, d);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const aliases = items.map((i) => i.alias);
|
|
499
|
+
if (new Set([...dimensions, ...aliases]).size !== dimensions.length + aliases.length) {
|
|
500
|
+
throw badRequest("$apply: an alias repeats a property or another alias");
|
|
501
|
+
}
|
|
502
|
+
const aggregate = (item) => {
|
|
503
|
+
if (item.method === "count") {
|
|
504
|
+
return sql `count(*)`;
|
|
505
|
+
}
|
|
506
|
+
const name = item.property ?? "";
|
|
507
|
+
if (!info.properties.has(name)) {
|
|
508
|
+
throw badRequest(`$apply: ${info.type.name} has no property ${name}`, name);
|
|
509
|
+
}
|
|
510
|
+
const column = sql.ref(`${alias}.${name}`);
|
|
511
|
+
switch (item.method) {
|
|
512
|
+
case "sum":
|
|
513
|
+
return sql `coalesce(sum(${column}), 0)`;
|
|
514
|
+
case "average":
|
|
515
|
+
return sql `avg(${column})`;
|
|
516
|
+
case "countdistinct":
|
|
517
|
+
return sql `count(DISTINCT ${column})`;
|
|
518
|
+
default:
|
|
519
|
+
return sql `${sql.raw(item.method)}(${column})`;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
const columns = [...dimensions.map((d) => sql.ref(`${alias}.${d}`)), ...items.map((i) => sql `${aggregate(i)} AS ${sql.id(i.alias)}`)];
|
|
523
|
+
if (columns.length === 0) {
|
|
524
|
+
throw badRequest("$apply: groupby or aggregate is needed");
|
|
525
|
+
}
|
|
526
|
+
const where = this.where(info, alias, options, [...scope, ...filters]);
|
|
527
|
+
const groupBy = dimensions.length === 0 ? sql `` : sql `GROUP BY ${sql.join(dimensions.map((d) => sql.ref(`${alias}.${d}`)))}`;
|
|
528
|
+
const order = (options.orderby ?? []).map((o) => {
|
|
529
|
+
const name = o.path.join("/");
|
|
530
|
+
if (!dimensions.includes(name) && !aliases.includes(name)) {
|
|
531
|
+
throw badRequest(`$orderby: ${name} is not a property or an alias of $apply`);
|
|
532
|
+
}
|
|
533
|
+
return sql `${sql.id(name)} ${sql.raw(o.descending ? "DESC NULLS LAST" : "ASC NULLS FIRST")}`;
|
|
534
|
+
});
|
|
535
|
+
for (const d of dimensions) {
|
|
536
|
+
order.push(sql `${sql.id(d)} ASC`);
|
|
537
|
+
}
|
|
538
|
+
const orderBy = order.length === 0 ? sql `` : sql `ORDER BY ${sql.join(order)}`;
|
|
539
|
+
const inner = sql `SELECT ${sql.join(columns)} FROM ${sql.table(info.view)} AS ${sql.id(alias)} ${where} ${groupBy}`;
|
|
540
|
+
const limit = Math.min(options.top ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE);
|
|
541
|
+
const { rows } = await sql `SELECT * FROM (${inner}) AS "g" ${orderBy} LIMIT ${limit} OFFSET ${options.skip ?? 0}`.execute(this.db());
|
|
542
|
+
const result = {
|
|
543
|
+
rows: rows.map((raw) => {
|
|
544
|
+
const row = {};
|
|
545
|
+
for (const d of dimensions) {
|
|
546
|
+
row[d] = fromSql(this.service.property(info, d).field.type, raw[d]);
|
|
547
|
+
}
|
|
548
|
+
for (const a of aliases) {
|
|
549
|
+
const value = raw[a];
|
|
550
|
+
row[a] = value === null || value === undefined ? 0 : Number(value);
|
|
551
|
+
}
|
|
552
|
+
return row;
|
|
553
|
+
}),
|
|
554
|
+
properties: dimensions,
|
|
555
|
+
aliases,
|
|
556
|
+
};
|
|
557
|
+
if (options.count === true) {
|
|
558
|
+
const counted = await sql `SELECT count(*) AS n FROM (${inner}) AS "g"`.execute(this.db());
|
|
559
|
+
result.count = Number(counted.rows[0]?.n ?? 0);
|
|
560
|
+
}
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
async count(info, options, scope = []) {
|
|
564
|
+
if (info.entity.kind === "custom") {
|
|
565
|
+
const scoped = this.scopeConditions(scope);
|
|
566
|
+
const filter = options.filter === undefined ? undefined : this.customQueries().condition(info, options.filter);
|
|
567
|
+
const result = await this.customQueries().select(info, { ...(filter === undefined ? {} : { filter }), scope: scoped, ...(options.search === undefined ? {} : { search: options.search }), count: true, data: false, properties: [] });
|
|
568
|
+
return result.total ?? 0;
|
|
569
|
+
}
|
|
570
|
+
const alias = "t0";
|
|
571
|
+
const where = this.where(info, alias, { ...(options.filter === undefined ? {} : { filter: options.filter }), ...(options.search === undefined ? {} : { search: options.search }) }, scope);
|
|
572
|
+
const { rows } = await sql `SELECT count(*) AS n FROM ${sql.table(info.view)} AS ${sql.id(alias)} ${where}`.execute(this.db());
|
|
573
|
+
return Number(rows[0]?.n ?? 0);
|
|
574
|
+
}
|
|
575
|
+
async readOne(info, key, options) {
|
|
576
|
+
const result = await this.readCollection(info, { ...options, top: 1, skip: 0, count: false, orderby: [] }, this.keyCondition(info, "t0", key), false);
|
|
577
|
+
return result.rows[0];
|
|
578
|
+
}
|
|
579
|
+
/** Scope of a navigation from a source instance: conditions on the target alias t0. */
|
|
580
|
+
async navigationScope(source, key, navigation) {
|
|
581
|
+
const { navigation: nav, target } = this.service.navigationTarget(source, navigation);
|
|
582
|
+
const parent = await this.readOne(source, key, { select: nav.on.map((p) => [p.source]) });
|
|
583
|
+
if (parent === undefined) {
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
if (target.entity.kind === "custom") {
|
|
587
|
+
return { target, scope: [this.customScope(this.equalities(target, nav.on.map((p) => p.target), nav.on.map((p) => parent[p.source])))], collection: nav.collection };
|
|
588
|
+
}
|
|
589
|
+
const scope = nav.on.map((p) => {
|
|
590
|
+
const { field } = this.service.property(target, p.target);
|
|
591
|
+
const value = parent[p.source];
|
|
592
|
+
return value === null ? sql `${sql.ref(`t0.${p.target}`)} IS NULL` : sql `${sql.ref(`t0.${p.target}`)} = ${toSql(field.type, value)}`;
|
|
593
|
+
});
|
|
594
|
+
return { target, scope, collection: nav.collection };
|
|
595
|
+
}
|
|
596
|
+
neededForExpand(info, expand) {
|
|
597
|
+
const needed = [];
|
|
598
|
+
for (const item of expand ?? []) {
|
|
599
|
+
if (item.path[0] === "*") {
|
|
600
|
+
for (const nav of info.navigations.values()) {
|
|
601
|
+
needed.push(...nav.on.map((p) => p.source));
|
|
602
|
+
}
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const nav = info.navigations.get(item.path[0] ?? "");
|
|
606
|
+
if (nav !== undefined) {
|
|
607
|
+
needed.push(...nav.on.map((p) => p.source));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return needed;
|
|
611
|
+
}
|
|
612
|
+
/** $expand, one level: one query per navigation for all the rows of the page. */
|
|
613
|
+
async expand(info, rows, expand) {
|
|
614
|
+
if (expand === undefined || rows.length === 0) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const items = expand.flatMap((item) => (item.path[0] === "*" ? [...info.navigations.keys()].map((n) => ({ path: [n], options: {} })) : [item]));
|
|
618
|
+
for (const item of items) {
|
|
619
|
+
if (item.path.length !== 1) {
|
|
620
|
+
throw notImplemented(`$expand of the path ${item.path.join("/")}`);
|
|
621
|
+
}
|
|
622
|
+
if (item.options.expand !== undefined) {
|
|
623
|
+
throw notImplemented("Nested $expand (one level is supported)");
|
|
624
|
+
}
|
|
625
|
+
const { navigation, target } = this.service.navigationTarget(info, item.path[0]);
|
|
626
|
+
const alias = "t0";
|
|
627
|
+
const targetFields = navigation.on.map((p) => p.target);
|
|
628
|
+
const needed = [...targetFields];
|
|
629
|
+
const properties = this.selectedProperties(target, item.options, needed);
|
|
630
|
+
if (target.entity.kind === "custom") {
|
|
631
|
+
await this.customExpand(rows, target, navigation, item, properties, needed);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const tuples = rows.map((r) => navigation.on.map((p) => r[p.source]));
|
|
635
|
+
const distinct = [...new Map(tuples.map((t) => [JSON.stringify(t), t])).values()].filter((t) => t.every((v) => v !== null));
|
|
636
|
+
const attach = (matches) => {
|
|
637
|
+
for (const row of rows) {
|
|
638
|
+
const mine = matches.filter((m) => navigation.on.every((p) => m[p.target] === row[p.source]));
|
|
639
|
+
row[navigation.name] = navigation.collection ? mine.map((m) => strip(m, properties, needed)) : mine[0] === undefined ? null : strip(mine[0], properties, needed);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
if (distinct.length === 0) {
|
|
643
|
+
attach([]);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const inCondition = sql `(${sql.join(targetFields.map((f) => sql.ref(`${alias}.${f}`)))}) IN (${sql.join(distinct.map((t) => sql `(${sql.join(t.map((v, i) => sql `${toSql(this.service.property(target, targetFields[i]).field.type, v)}`))})`))})`;
|
|
647
|
+
const where = this.where(target, alias, item.options, [inCondition]);
|
|
648
|
+
const columns = this.columns(target, alias, properties);
|
|
649
|
+
let query;
|
|
650
|
+
if (navigation.collection && (item.options.top !== undefined || item.options.skip !== undefined)) {
|
|
651
|
+
const partition = sql.join(targetFields.map((f) => sql.ref(`${alias}.${f}`)));
|
|
652
|
+
const from = item.options.skip ?? 0;
|
|
653
|
+
const to = item.options.top === undefined ? null : from + item.options.top;
|
|
654
|
+
query = sql `SELECT * FROM (SELECT ${columns}, row_number() OVER (PARTITION BY ${partition} ${this.orderBy(target, alias, item.options.orderby)}) AS "__rn" FROM ${sql.table(target.view)} AS ${sql.id(alias)} ${where}) AS "x" WHERE "__rn" > ${from} ${to === null ? sql `` : sql `AND "__rn" <= ${to}`} ORDER BY "__rn"`;
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
query = sql `SELECT ${columns} FROM ${sql.table(target.view)} AS ${sql.id(alias)} ${where} ${this.orderBy(target, alias, item.options.orderby)}`;
|
|
658
|
+
}
|
|
659
|
+
const { rows: raw } = await query.execute(this.db());
|
|
660
|
+
const matches = raw.map((r) => this.rowFrom(target, r, properties));
|
|
661
|
+
attach(matches);
|
|
662
|
+
if (navigation.collection && item.options.count === true) {
|
|
663
|
+
const countWhere = this.where(target, alias, { ...(item.options.filter === undefined ? {} : { filter: item.options.filter }) }, [inCondition]);
|
|
664
|
+
const grouped = await sql `SELECT ${sql.join(targetFields.map((f) => sql.ref(`${alias}.${f}`)))}, count(*) AS "__n" FROM ${sql.table(target.view)} AS ${sql.id(alias)} ${countWhere} GROUP BY ${sql.join(targetFields.map((f) => sql.ref(`${alias}.${f}`)))}`.execute(this.db());
|
|
665
|
+
const counts = grouped.rows.map((g) => ({ key: targetFields.map((f) => fromSql(this.service.property(target, f).field.type, g[f])), n: Number(g["__n"]) }));
|
|
666
|
+
for (const row of rows) {
|
|
667
|
+
const found = counts.find((c) => navigation.on.every((p, i) => c.key[i] === row[p.source]));
|
|
668
|
+
row[`${navigation.name}@odata.count`] = found?.n ?? 0;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/** Removes the fields read only to join, unless selected. */
|
|
675
|
+
function strip(row, properties, needed) {
|
|
676
|
+
void needed;
|
|
677
|
+
const copy = {};
|
|
678
|
+
for (const p of properties) {
|
|
679
|
+
copy[p] = row[p];
|
|
680
|
+
}
|
|
681
|
+
if (row[DEPENDENT_ETAG] !== undefined) {
|
|
682
|
+
copy[DEPENDENT_ETAG] = row[DEPENDENT_ETAG];
|
|
683
|
+
}
|
|
684
|
+
return copy;
|
|
685
|
+
}
|
|
686
|
+
//# sourceMappingURL=read.js.map
|