@firedrill-tools/netsuite 0.1.2
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 +201 -0
- package/README.md +214 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +6905 -0
- package/firedrill/concurrency-limited.scenario.json +11 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/ns-analytics.drill.json +73 -0
- package/firedrill/ns-concurrency.drill.json +53 -0
- package/firedrill/ns-default-identity.drill.json +43 -0
- package/firedrill/ns-denied.drill.json +38 -0
- package/firedrill/ns-insufficient-permission.drill.json +53 -0
- package/firedrill/ns-invalid-login.drill.json +53 -0
- package/firedrill/ns-order-to-cash.drill.json +93 -0
- package/firedrill/ns-record-locked.drill.json +53 -0
- package/firedrill/ns-records.drill.json +68 -0
- package/firedrill/ns-role-ar-clerk.drill.json +58 -0
- package/firedrill/ns-role-sales-rep.drill.json +58 -0
- package/firedrill/ns-subsidiary-scope.drill.json +58 -0
- package/firedrill/ns-tight-limits.drill.json +58 -0
- package/firedrill/ns-transform-lost.drill.json +63 -0
- package/firedrill/ns-write-outage.drill.json +63 -0
- package/firedrill/record-locked.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +17 -0
- package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
- package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
- package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/app.js +182 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/icons.js +59 -0
- package/firedrill/tools/netsuite/app/site/index.html +68 -0
- package/firedrill/tools/netsuite/app/site/menus.js +111 -0
- package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
- package/firedrill/tools/netsuite/app/site/store.js +95 -0
- package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
- package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
- package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
- package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
- package/firedrill/tools/netsuite/app/site/ui.js +119 -0
- package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
- package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
- package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
- package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
- package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
- package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
- package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
- package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
- package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
- package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
- package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
- package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
- package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
- package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
- package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
- package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
- package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
- package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
- package/firedrill/tools/netsuite/behavior.mjs +13 -0
- package/firedrill/tools/netsuite/lib/body.mjs +185 -0
- package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
- package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
- package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
- package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
- package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
- package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
- package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
- package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
- package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
- package/firedrill/tools/netsuite/lib/project.mjs +178 -0
- package/firedrill/tools/netsuite/lib/q.mjs +280 -0
- package/firedrill/tools/netsuite/lib/session.mjs +188 -0
- package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
- package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
- package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
- package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
- package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
- package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
- package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
- package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
- package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
- package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
- package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
- package/firedrill/transform-response-lost.scenario.json +11 -0
- package/firedrill/world.json +7711 -0
- package/firedrill/write-outage.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +6904 -0
- package/test/conformance.mjs +701 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Caller identity (account → role → subsidiary scope), bounded state access and the per-operation session.
|
|
2
|
+
import { fail, LOGIN_DETAIL } from "./errors.mjs";
|
|
3
|
+
import { accountDate, isInternalId, timestamp } from "./primitives.mjs";
|
|
4
|
+
|
|
5
|
+
const LEVELS = ["none", "view", "create", "edit", "full"];
|
|
6
|
+
const rank = (level) => {
|
|
7
|
+
const index = LEVELS.indexOf(level);
|
|
8
|
+
return index < 0 ? 0 : index;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const PERMISSION_LABELS = new Map([
|
|
12
|
+
["LIST_CUSTJOB", "Customers"],
|
|
13
|
+
["TRAN_SALESORD", "Sales Order"],
|
|
14
|
+
["TRAN_CUSTINVC", "Invoice"],
|
|
15
|
+
["TRAN_CUSTPYMT", "Customer Payment"],
|
|
16
|
+
["LIST_ITEM", "Items"],
|
|
17
|
+
["LIST_SUBSIDIARY", "Subsidiaries"],
|
|
18
|
+
["SETUP_RECORD_METADATA", "SuiteAnalytics Workbook"],
|
|
19
|
+
["REPO_ANALYTICS", "SuiteAnalytics Workbook"],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const DEFAULT_ROLE_ID = "3";
|
|
23
|
+
const DEFAULT_OFFSET = -480;
|
|
24
|
+
|
|
25
|
+
function attribute(attributes, name) {
|
|
26
|
+
return Object.hasOwn(attributes, name) ? attributes[name] : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Open a session for one operation call. Applies account → role → employee, before any permission check. */
|
|
30
|
+
export function open(context) {
|
|
31
|
+
const account = context.state.get("meta", "account");
|
|
32
|
+
if (account === null) {
|
|
33
|
+
fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
34
|
+
}
|
|
35
|
+
const attributes = context.actor.attributes ?? {};
|
|
36
|
+
const claimedAccount = attribute(attributes, "netsuiteAccountId");
|
|
37
|
+
if (claimedAccount !== undefined && claimedAccount !== account.accountId) {
|
|
38
|
+
fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
39
|
+
}
|
|
40
|
+
const roleId = attribute(attributes, "netsuiteRoleId") ?? DEFAULT_ROLE_ID;
|
|
41
|
+
if (!isInternalId(roleId)) fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
42
|
+
const role = context.state.get("roles", roleId);
|
|
43
|
+
if (role === null) fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
44
|
+
|
|
45
|
+
const employeeId = attribute(attributes, "netsuiteEmployeeId") ?? account.defaultEmployeeId;
|
|
46
|
+
if (!isInternalId(employeeId)) fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
47
|
+
const employee = context.state.get("employees", employeeId);
|
|
48
|
+
if (employee === null) fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
49
|
+
|
|
50
|
+
const claimedScope = attribute(attributes, "netsuiteSubsidiaryId");
|
|
51
|
+
let scope = typeof role.subsidiaryId === "string" ? role.subsidiaryId : "0";
|
|
52
|
+
if (claimedScope !== undefined) {
|
|
53
|
+
if (typeof claimedScope !== "string" || !/^[0-9]{1,15}$/.test(claimedScope)) {
|
|
54
|
+
fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
55
|
+
}
|
|
56
|
+
scope = claimedScope;
|
|
57
|
+
}
|
|
58
|
+
if (scope !== "0" && context.state.get("subsidiaries", scope) === null) {
|
|
59
|
+
fail(context, "INVALID_LOGIN", LOGIN_DETAIL);
|
|
60
|
+
}
|
|
61
|
+
return createSession(context, account, role, employee, scope);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NUMBERED = new Set(["customers", "items", "sales-orders", "invoices", "customer-payments", "subsidiaries", "employees"]);
|
|
65
|
+
|
|
66
|
+
function createSession(context, account, role, employee, scope) {
|
|
67
|
+
const limits = context.state.get("meta", "limits");
|
|
68
|
+
const maxScanRows = limits !== null && Number.isInteger(limits.maxScanRows) ? limits.maxScanRows : 5000;
|
|
69
|
+
const maxPageBytes = limits !== null && Number.isInteger(limits.maxPageBytes) ? limits.maxPageBytes : 900000;
|
|
70
|
+
const cache = new Map();
|
|
71
|
+
const nowUs = context.clock.nowUs();
|
|
72
|
+
|
|
73
|
+
const session = {
|
|
74
|
+
context,
|
|
75
|
+
account,
|
|
76
|
+
role,
|
|
77
|
+
employee,
|
|
78
|
+
scope,
|
|
79
|
+
maxScanRows,
|
|
80
|
+
maxPageBytes,
|
|
81
|
+
nowUs,
|
|
82
|
+
now: timestamp(nowUs),
|
|
83
|
+
today: accountDate(nowUs, Number.isInteger(account.timeZoneOffsetMinutes) ? account.timeZoneOffsetMinutes : DEFAULT_OFFSET),
|
|
84
|
+
fail: (code, detail, extra = {}) => fail(context, code, detail, extra),
|
|
85
|
+
|
|
86
|
+
/** Enforce one NetSuite permission at a minimum level. */
|
|
87
|
+
permit(permission, level) {
|
|
88
|
+
if (role.isAdmin === true) return;
|
|
89
|
+
const granted = role.permissions !== undefined && Object.hasOwn(role.permissions, permission)
|
|
90
|
+
? role.permissions[permission]
|
|
91
|
+
: "none";
|
|
92
|
+
if (rank(granted) < rank(level)) {
|
|
93
|
+
session.fail(
|
|
94
|
+
"INSUFFICIENT_PERMISSION",
|
|
95
|
+
`Permission Violation: You need a higher level of the '${PERMISSION_LABELS.get(permission) ?? permission}' permission to access this page. Please contact your account administrator.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** Every row of one namespace, id-ascending, bounded by maxScanRows. */
|
|
101
|
+
rows(namespace) {
|
|
102
|
+
if (cache.has(namespace)) return cache.get(namespace);
|
|
103
|
+
const scanned = context.state.scan(namespace, { limit: maxScanRows });
|
|
104
|
+
if (scanned.length >= maxScanRows) {
|
|
105
|
+
session.fail(
|
|
106
|
+
"RESULT_SET_TOO_LARGE",
|
|
107
|
+
`Search error occurred: The result set for ${namespace} is larger than the supported maximum of ${maxScanRows} rows. Narrow the request and try again.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const values = scanned.map((record) => record.value);
|
|
111
|
+
if (NUMBERED.has(namespace)) values.sort((left, right) => Number(left.id) - Number(right.id));
|
|
112
|
+
else values.sort((left, right) => (left.id ?? "") < (right.id ?? "") ? -1 : 1);
|
|
113
|
+
cache.set(namespace, values);
|
|
114
|
+
return values;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
get(namespace, rowId) {
|
|
118
|
+
return isInternalId(rowId) ? context.state.get(namespace, rowId) : null;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
put(namespace, rowId, value) {
|
|
122
|
+
context.state.put(namespace, rowId, value);
|
|
123
|
+
cache.delete(namespace);
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
remove(namespace, rowId) {
|
|
127
|
+
const removed = context.state.delete(namespace, rowId);
|
|
128
|
+
cache.delete(namespace);
|
|
129
|
+
return removed;
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/** True when a record's subsidiary is inside the effective scope (the scope or one of its descendants). */
|
|
133
|
+
inScope(subsidiaryId) {
|
|
134
|
+
if (scope === "0") return true;
|
|
135
|
+
if (subsidiaryId === scope) return true;
|
|
136
|
+
let current = context.state.get("subsidiaries", subsidiaryId);
|
|
137
|
+
for (let depth = 0; depth < 16 && current !== null; depth += 1) {
|
|
138
|
+
if (current.parentId === null || current.parentId === undefined) return false;
|
|
139
|
+
if (current.parentId === scope) return true;
|
|
140
|
+
current = context.state.get("subsidiaries", current.parentId);
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/** Draw the next id from one of the three NetSuite counters, skipping ids already taken. */
|
|
146
|
+
nextId(counterKey, namespace) {
|
|
147
|
+
const counters = context.state.get("meta", "counters");
|
|
148
|
+
const start = counters !== null && Number.isInteger(counters[counterKey]) ? counters[counterKey] : 1;
|
|
149
|
+
let next = start < 1 ? 1 : start;
|
|
150
|
+
for (let guard = 0; guard <= maxScanRows; guard += 1) {
|
|
151
|
+
if (context.state.get(namespace, String(next)) === null) break;
|
|
152
|
+
next += 1;
|
|
153
|
+
if (guard === maxScanRows) {
|
|
154
|
+
session.fail("UNEXPECTED_ERROR", "An unexpected error occurred while allocating an internal id.");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const updated = { ...(counters ?? { nextEntityId: 1, nextItemId: 1, nextTransactionId: 1 }) };
|
|
158
|
+
updated[counterKey] = next + 1;
|
|
159
|
+
context.state.put("meta", "counters", updated);
|
|
160
|
+
return String(next);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
recordChanged(recordType, recordId, changeType, subsidiaryId) {
|
|
164
|
+
context.events.emit("record.changed", {
|
|
165
|
+
recordType,
|
|
166
|
+
recordId,
|
|
167
|
+
changeType,
|
|
168
|
+
occurredAt: session.now,
|
|
169
|
+
employeeId: employee.id,
|
|
170
|
+
subsidiaryId,
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
statusChanged(recordType, record, previousStatus, amountRemaining) {
|
|
175
|
+
context.events.emit("transaction.status-changed", {
|
|
176
|
+
recordType,
|
|
177
|
+
recordId: record.id,
|
|
178
|
+
tranId: record.tranId,
|
|
179
|
+
previousStatus,
|
|
180
|
+
status: record.status,
|
|
181
|
+
occurredAt: session.now,
|
|
182
|
+
total: record.total,
|
|
183
|
+
amountRemaining: amountRemaining ?? null,
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
return session;
|
|
188
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// SuiteQL subset tokenizer and parser. Hand-written; no regular expression is built from caller text.
|
|
2
|
+
import { quote } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
export const MAX_SQL = 8000;
|
|
5
|
+
const MAX_PREDICATES = 30;
|
|
6
|
+
const MAX_SELECT = 40;
|
|
7
|
+
const MAX_JOINS = 2;
|
|
8
|
+
const MAX_DEPTH = 10;
|
|
9
|
+
|
|
10
|
+
export class SqlError extends Error {}
|
|
11
|
+
export const sqlBad = (message) => {
|
|
12
|
+
throw new SqlError(message);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DIGIT = "0123456789";
|
|
16
|
+
const isDigit = (character) => DIGIT.indexOf(character) >= 0;
|
|
17
|
+
const isWordStart = (character) => /[A-Za-z_]/.test(character) === true;
|
|
18
|
+
const isWordPart = (character) => /[A-Za-z0-9_$#]/.test(character) === true;
|
|
19
|
+
|
|
20
|
+
const SYMBOLS = ["<>", "!=", "<=", ">=", "(", ")", ",", ".", "*", "=", "<", ">", "?", ";", "|"];
|
|
21
|
+
|
|
22
|
+
export function tokenizeSql(text) {
|
|
23
|
+
if (text.length > MAX_SQL) sqlBad(`a SuiteQL statement is limited to ${MAX_SQL} characters`);
|
|
24
|
+
const tokens = [];
|
|
25
|
+
let index = 0;
|
|
26
|
+
while (index < text.length) {
|
|
27
|
+
const character = text[index];
|
|
28
|
+
if (character === " " || character === "\t" || character === "\n" || character === "\r") {
|
|
29
|
+
index += 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (character === "\uFFFD") sqlBad(`unreadable character at position ${index}`);
|
|
33
|
+
if (character === "'") {
|
|
34
|
+
let value = "";
|
|
35
|
+
let cursor = index + 1;
|
|
36
|
+
let closed = false;
|
|
37
|
+
while (cursor < text.length) {
|
|
38
|
+
if (text[cursor] === "'") {
|
|
39
|
+
if (text[cursor + 1] === "'") {
|
|
40
|
+
value += "'";
|
|
41
|
+
cursor += 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
closed = true;
|
|
45
|
+
cursor += 1;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
value += text[cursor];
|
|
49
|
+
cursor += 1;
|
|
50
|
+
}
|
|
51
|
+
if (!closed) sqlBad(`unterminated string literal at position ${index}`);
|
|
52
|
+
tokens.push({ kind: "string", value, at: index });
|
|
53
|
+
index = cursor;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (isDigit(character)) {
|
|
57
|
+
let cursor = index;
|
|
58
|
+
let value = "";
|
|
59
|
+
let dots = 0;
|
|
60
|
+
while (cursor < text.length && (isDigit(text[cursor]) || text[cursor] === ".")) {
|
|
61
|
+
if (text[cursor] === ".") {
|
|
62
|
+
if (dots === 1) break;
|
|
63
|
+
if (!isDigit(text[cursor + 1] ?? "")) break;
|
|
64
|
+
dots += 1;
|
|
65
|
+
}
|
|
66
|
+
value += text[cursor];
|
|
67
|
+
cursor += 1;
|
|
68
|
+
}
|
|
69
|
+
tokens.push({ kind: "number", value: Number(value), at: index });
|
|
70
|
+
index = cursor;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (isWordStart(character)) {
|
|
74
|
+
let cursor = index;
|
|
75
|
+
let value = "";
|
|
76
|
+
while (cursor < text.length && isWordPart(text[cursor])) {
|
|
77
|
+
value += text[cursor];
|
|
78
|
+
cursor += 1;
|
|
79
|
+
}
|
|
80
|
+
tokens.push({ kind: "word", value, upper: value.toUpperCase(), at: index });
|
|
81
|
+
index = cursor;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const symbol = SYMBOLS.find((candidate) => text.startsWith(candidate, index));
|
|
85
|
+
if (symbol === undefined) sqlBad(`unexpected character at position ${index}`);
|
|
86
|
+
tokens.push({ kind: symbol, at: index });
|
|
87
|
+
index += symbol.length;
|
|
88
|
+
}
|
|
89
|
+
return tokens;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const UNSUPPORTED = new Set([
|
|
93
|
+
"INSERT", "UPDATE", "DELETE", "MERGE", "CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT",
|
|
94
|
+
"UNION", "INTERSECT", "MINUS", "CASE", "OVER", "PARTITION", "WITH",
|
|
95
|
+
]);
|
|
96
|
+
const AGGREGATES = new Set(["COUNT", "SUM", "MIN", "MAX", "AVG"]);
|
|
97
|
+
const SCALARS = new Set(["UPPER", "LOWER", "NVL"]);
|
|
98
|
+
|
|
99
|
+
/** Parse a single SuiteQL SELECT; returns a plan object or throws SqlError. */
|
|
100
|
+
export function parseSql(text) {
|
|
101
|
+
const tokens = tokenizeSql(text);
|
|
102
|
+
let position = 0;
|
|
103
|
+
let predicates = 0;
|
|
104
|
+
let paramCount = 0;
|
|
105
|
+
const peek = (ahead = 0) => (position + ahead < tokens.length ? tokens[position + ahead] : null);
|
|
106
|
+
const next = () => {
|
|
107
|
+
const token = peek();
|
|
108
|
+
if (token === null) sqlBad("unexpected end of the SuiteQL statement");
|
|
109
|
+
position += 1;
|
|
110
|
+
return token;
|
|
111
|
+
};
|
|
112
|
+
const isWord = (token, upper) => token !== null && token.kind === "word" && token.upper === upper;
|
|
113
|
+
const acceptWord = (upper) => {
|
|
114
|
+
if (isWord(peek(), upper)) {
|
|
115
|
+
position += 1;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
const expectWord = (upper) => {
|
|
121
|
+
if (!acceptWord(upper)) sqlBad(`expected ${upper} in the SuiteQL statement`);
|
|
122
|
+
};
|
|
123
|
+
const accept = (kind) => {
|
|
124
|
+
if (peek() !== null && peek().kind === kind) {
|
|
125
|
+
position += 1;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
const expect = (kind) => {
|
|
131
|
+
if (!accept(kind)) sqlBad(`expected ${kind} in the SuiteQL statement`);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (const token of tokens) {
|
|
135
|
+
if (token.kind === "word" && UNSUPPORTED.has(token.upper)) {
|
|
136
|
+
sqlBad(`${quote(token.value)} is not supported by this SuiteQL subset`);
|
|
137
|
+
}
|
|
138
|
+
if (token.kind === ";" && token !== tokens[tokens.length - 1]) {
|
|
139
|
+
sqlBad("only a single SuiteQL statement is supported");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expectWord("SELECT");
|
|
144
|
+
const distinct = acceptWord("DISTINCT");
|
|
145
|
+
const select = [];
|
|
146
|
+
for (;;) {
|
|
147
|
+
select.push(selectItem());
|
|
148
|
+
if (select.length > MAX_SELECT) sqlBad(`a SuiteQL select list is limited to ${MAX_SELECT} items`);
|
|
149
|
+
if (!accept(",")) break;
|
|
150
|
+
}
|
|
151
|
+
expectWord("FROM");
|
|
152
|
+
const from = tableRef();
|
|
153
|
+
const joins = [];
|
|
154
|
+
for (;;) {
|
|
155
|
+
const kind = acceptWord("INNER") ? "inner" : acceptWord("LEFT") ? "left" : null;
|
|
156
|
+
if (kind !== null) acceptWord("OUTER");
|
|
157
|
+
if (!isWord(peek(), "JOIN")) {
|
|
158
|
+
if (kind !== null) sqlBad("expected JOIN after the join type");
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
position += 1;
|
|
162
|
+
const table = tableRef();
|
|
163
|
+
expectWord("ON");
|
|
164
|
+
joins.push({ kind: kind ?? "inner", table, on: predicate(1) });
|
|
165
|
+
if (joins.length > MAX_JOINS) sqlBad(`a SuiteQL statement is limited to ${MAX_JOINS} joins`);
|
|
166
|
+
}
|
|
167
|
+
const where = acceptWord("WHERE") ? predicate(1) : null;
|
|
168
|
+
let groupBy = [];
|
|
169
|
+
if (acceptWord("GROUP")) {
|
|
170
|
+
expectWord("BY");
|
|
171
|
+
for (;;) {
|
|
172
|
+
groupBy.push(columnRef());
|
|
173
|
+
if (!accept(",")) break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const having = acceptWord("HAVING") ? predicate(1) : null;
|
|
177
|
+
let orderBy = [];
|
|
178
|
+
if (acceptWord("ORDER")) {
|
|
179
|
+
expectWord("BY");
|
|
180
|
+
for (;;) {
|
|
181
|
+
const expression = selectItem().expression;
|
|
182
|
+
const direction = acceptWord("DESC") ? "desc" : (acceptWord("ASC"), "asc");
|
|
183
|
+
orderBy.push({ expression, direction });
|
|
184
|
+
if (!accept(",")) break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
let fetch = null;
|
|
188
|
+
if (acceptWord("FETCH")) {
|
|
189
|
+
if (!acceptWord("FIRST")) expectWord("NEXT");
|
|
190
|
+
const count = next();
|
|
191
|
+
if (count.kind !== "number") sqlBad("FETCH FIRST needs a row count");
|
|
192
|
+
fetch = count.value;
|
|
193
|
+
if (!acceptWord("ROWS")) expectWord("ROW");
|
|
194
|
+
expectWord("ONLY");
|
|
195
|
+
}
|
|
196
|
+
accept(";");
|
|
197
|
+
if (position !== tokens.length) sqlBad(`unexpected token at position ${tokens[position].at}`);
|
|
198
|
+
return { distinct, select, from, joins, where, groupBy, having, orderBy, fetch, paramCount };
|
|
199
|
+
|
|
200
|
+
function tableRef() {
|
|
201
|
+
const name = next();
|
|
202
|
+
if (name.kind !== "word") sqlBad("expected a table name");
|
|
203
|
+
let alias = null;
|
|
204
|
+
if (acceptWord("AS")) {
|
|
205
|
+
const token = next();
|
|
206
|
+
if (token.kind !== "word") sqlBad("expected a table alias after AS");
|
|
207
|
+
alias = token.value;
|
|
208
|
+
} else if (peek() !== null && peek().kind === "word" && !RESERVED.has(peek().upper)) {
|
|
209
|
+
alias = next().value;
|
|
210
|
+
}
|
|
211
|
+
return { name: name.value, alias };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function columnRef() {
|
|
215
|
+
const first = next();
|
|
216
|
+
if (first.kind !== "word") sqlBad("expected a column name");
|
|
217
|
+
if (peek() !== null && peek().kind === ".") {
|
|
218
|
+
position += 1;
|
|
219
|
+
if (peek() !== null && peek().kind === "*") {
|
|
220
|
+
position += 1;
|
|
221
|
+
return { kind: "star", qualifier: first.value };
|
|
222
|
+
}
|
|
223
|
+
const column = next();
|
|
224
|
+
if (column.kind !== "word") sqlBad("expected a column name after .");
|
|
225
|
+
return { kind: "column", qualifier: first.value, name: column.value };
|
|
226
|
+
}
|
|
227
|
+
return { kind: "column", qualifier: null, name: first.value };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function selectItem() {
|
|
231
|
+
const expression = valueExpression();
|
|
232
|
+
let alias = null;
|
|
233
|
+
if (acceptWord("AS")) {
|
|
234
|
+
const token = next();
|
|
235
|
+
if (token.kind !== "word") sqlBad("expected a column alias after AS");
|
|
236
|
+
alias = token.value;
|
|
237
|
+
} else if (peek() !== null && peek().kind === "word" && !RESERVED.has(peek().upper)) {
|
|
238
|
+
alias = next().value;
|
|
239
|
+
}
|
|
240
|
+
return { expression, alias };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function valueExpression() {
|
|
244
|
+
const token = peek();
|
|
245
|
+
if (token === null) sqlBad("unexpected end of the SuiteQL statement");
|
|
246
|
+
if (token.kind === "*") {
|
|
247
|
+
position += 1;
|
|
248
|
+
return { kind: "star", qualifier: null };
|
|
249
|
+
}
|
|
250
|
+
if (token.kind === "string") {
|
|
251
|
+
position += 1;
|
|
252
|
+
return { kind: "literal", value: token.value };
|
|
253
|
+
}
|
|
254
|
+
if (token.kind === "number") {
|
|
255
|
+
position += 1;
|
|
256
|
+
return { kind: "literal", value: token.value };
|
|
257
|
+
}
|
|
258
|
+
if (token.kind === "?") {
|
|
259
|
+
position += 1;
|
|
260
|
+
paramCount += 1;
|
|
261
|
+
return { kind: "param", index: paramCount - 1 };
|
|
262
|
+
}
|
|
263
|
+
if (token.kind === "(") {
|
|
264
|
+
position += 1;
|
|
265
|
+
const inner = valueExpression();
|
|
266
|
+
expect(")");
|
|
267
|
+
return inner;
|
|
268
|
+
}
|
|
269
|
+
if (token.kind !== "word") sqlBad(`unexpected token at position ${token.at}`);
|
|
270
|
+
if (token.upper === "NULL") {
|
|
271
|
+
position += 1;
|
|
272
|
+
return { kind: "literal", value: null };
|
|
273
|
+
}
|
|
274
|
+
if (token.upper === "BUILTIN" && peek(1) !== null && peek(1).kind === ".") {
|
|
275
|
+
position += 2;
|
|
276
|
+
const fn = next();
|
|
277
|
+
if (fn.kind !== "word" || fn.upper !== "DF") sqlBad("only BUILTIN.DF is supported");
|
|
278
|
+
expect("(");
|
|
279
|
+
const column = columnRef();
|
|
280
|
+
expect(")");
|
|
281
|
+
return { kind: "df", column };
|
|
282
|
+
}
|
|
283
|
+
if (peek(1) !== null && peek(1).kind === "(" && (AGGREGATES.has(token.upper) || SCALARS.has(token.upper))) {
|
|
284
|
+
position += 2;
|
|
285
|
+
const name = token.upper;
|
|
286
|
+
const args = [];
|
|
287
|
+
if (name === "COUNT" && peek() !== null && peek().kind === "*") {
|
|
288
|
+
position += 1;
|
|
289
|
+
expect(")");
|
|
290
|
+
return { kind: "aggregate", name, args: [{ kind: "star", qualifier: null }] };
|
|
291
|
+
}
|
|
292
|
+
const distinctArgument = acceptWord("DISTINCT");
|
|
293
|
+
for (;;) {
|
|
294
|
+
args.push(valueExpression());
|
|
295
|
+
if (!accept(",")) break;
|
|
296
|
+
}
|
|
297
|
+
expect(")");
|
|
298
|
+
return AGGREGATES.has(name)
|
|
299
|
+
? { kind: "aggregate", name, args, distinct: distinctArgument }
|
|
300
|
+
: { kind: "call", name, args };
|
|
301
|
+
}
|
|
302
|
+
return columnRef();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function predicate(depth) {
|
|
306
|
+
if (depth > MAX_DEPTH) sqlBad(`the SuiteQL predicate nests deeper than ${MAX_DEPTH} levels`);
|
|
307
|
+
let node = predicateTerm(depth);
|
|
308
|
+
while (acceptWord("OR")) node = { kind: "or", left: node, right: predicateTerm(depth) };
|
|
309
|
+
return node;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function predicateTerm(depth) {
|
|
313
|
+
let node = predicateFactor(depth);
|
|
314
|
+
while (acceptWord("AND")) node = { kind: "and", left: node, right: predicateFactor(depth) };
|
|
315
|
+
return node;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function predicateFactor(depth) {
|
|
319
|
+
if (acceptWord("NOT")) return { kind: "not", operand: predicateFactor(depth) };
|
|
320
|
+
if (peek() !== null && peek().kind === "(" && looksLikeGroup()) {
|
|
321
|
+
position += 1;
|
|
322
|
+
const inner = predicate(depth + 1);
|
|
323
|
+
expect(")");
|
|
324
|
+
return inner;
|
|
325
|
+
}
|
|
326
|
+
predicates += 1;
|
|
327
|
+
if (predicates > MAX_PREDICATES) sqlBad(`a SuiteQL statement is limited to ${MAX_PREDICATES} predicates`);
|
|
328
|
+
const left = valueExpression();
|
|
329
|
+
if (acceptWord("IS")) {
|
|
330
|
+
const negated = acceptWord("NOT");
|
|
331
|
+
expectWord("NULL");
|
|
332
|
+
return { kind: "isNull", operand: left, negated };
|
|
333
|
+
}
|
|
334
|
+
const negated = acceptWord("NOT");
|
|
335
|
+
if (acceptWord("LIKE")) return { kind: "like", left, right: valueExpression(), negated };
|
|
336
|
+
if (acceptWord("BETWEEN")) {
|
|
337
|
+
const low = valueExpression();
|
|
338
|
+
expectWord("AND");
|
|
339
|
+
return { kind: "between", left, low, high: valueExpression(), negated };
|
|
340
|
+
}
|
|
341
|
+
if (acceptWord("IN")) {
|
|
342
|
+
expect("(");
|
|
343
|
+
const values = [];
|
|
344
|
+
for (;;) {
|
|
345
|
+
values.push(valueExpression());
|
|
346
|
+
if (!accept(",")) break;
|
|
347
|
+
}
|
|
348
|
+
expect(")");
|
|
349
|
+
return { kind: "in", left, values, negated };
|
|
350
|
+
}
|
|
351
|
+
if (negated) sqlBad("NOT must be followed by LIKE, BETWEEN or IN");
|
|
352
|
+
const operator = next();
|
|
353
|
+
const symbol = operator.kind;
|
|
354
|
+
if (symbol !== "=" && symbol !== "<" && symbol !== ">" && symbol !== "<=" && symbol !== ">=" && symbol !== "<>" && symbol !== "!=") {
|
|
355
|
+
sqlBad(`unsupported comparison operator at position ${operator.at}`);
|
|
356
|
+
}
|
|
357
|
+
return { kind: "compare", operator: symbol === "!=" ? "<>" : symbol, left, right: valueExpression() };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** A "(" starts a predicate group when the matching content contains a logical operator or comparison. */
|
|
361
|
+
function looksLikeGroup() {
|
|
362
|
+
let depth = 0;
|
|
363
|
+
for (let index = position; index < tokens.length; index += 1) {
|
|
364
|
+
const token = tokens[index];
|
|
365
|
+
if (token.kind === "(") depth += 1;
|
|
366
|
+
else if (token.kind === ")") {
|
|
367
|
+
depth -= 1;
|
|
368
|
+
if (depth === 0) return false;
|
|
369
|
+
} else if (depth === 1) {
|
|
370
|
+
if (token.kind === "word" && (token.upper === "AND" || token.upper === "OR" || token.upper === "NOT")) return true;
|
|
371
|
+
if (["=", "<", ">", "<=", ">=", "<>", "!="].includes(token.kind)) return true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const RESERVED = new Set([
|
|
379
|
+
"SELECT", "FROM", "WHERE", "GROUP", "BY", "HAVING", "ORDER", "ASC", "DESC", "JOIN", "INNER", "LEFT",
|
|
380
|
+
"RIGHT", "OUTER", "ON", "AND", "OR", "NOT", "IN", "IS", "NULL", "LIKE", "BETWEEN", "FETCH", "FIRST",
|
|
381
|
+
"NEXT", "ROWS", "ROW", "ONLY", "AS", "DISTINCT",
|
|
382
|
+
]);
|