Skip to content

Commit afb2810

Browse files
committed
Improve multi-line and multi-statement evaluation
* Execute multiple statements, separated by ';', individually * Parse comments out of statements * Execute statements sequentially, waiting for each to complete before executing the next
1 parent 428ca23 commit afb2810

File tree

2 files changed

+216
-109
lines changed

2 files changed

+216
-109
lines changed

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,25 @@ adasql will show you information up top to help you ensure you're connecting to
3939

4040
Transactions are supported, though note the Data API doesn't support save points or nested transactions.
4141

42-
### Canceling commands
43-
If you find yourself in the middle of a multi-line statement and wish to cancel it, enter `.clear`. This will reset the state of the REPL, though it will not affect a transaction if it is in progress.
42+
### Multiple statements and multi-line statements
43+
SQL statements are executed sequentially, and multiple statements can be executed when separated with the ';' delimiter. For example, the following query when executed will insert a new record and then return the same record as the first statement executes to completion before the second statement is executed:
44+
45+
```SQL
46+
INSERT INTO people (id, name, age) VALUES (1, 'Alice', 39); SELECT * from mytable WHERE id = 1;
47+
```
48+
49+
#### Canceling commands
50+
If you find yourself in the middle of a multi-line statement and wish to cancel it, enter `.clear`. This will reset the state of the REPL, though it will not affect a transaction if it is in progress.
51+
52+
## FAQ
53+
### Can adasql be used for migrations, seeding data, or restoring backups?
54+
Maybe! But for most use cases adasql will not work. The Aurora Data API has three issues that make migrations and restoring backups difficult:
55+
56+
* SQL connections to the DB used by the Data API are multiplexed, and there is no way to ensure affinity for statements that set per-connection variables. This is important when restoring backups where the backups need to execute commands like `SET FOREIGN_KEY_CHECKS=0` to disable foreign key checks when inserting records using the same connection. Multiple statements that need to be executed on the same connection with these connection-specific variables will likely fail part-way through execution.
57+
* SQL statements larger than 64 KB are not supported. If you want to insert a batch of records, make sure they are separated into chunks of statements smaller than 64 KB.
58+
* SQL statements that take longer than 45 seconds to complete will timeout. The adasql client does not tell the Data API to continue these statements if they do timeout for data integrity purposes. But, DDL statements that timeout may cause non-reversible changes when canceled.
59+
60+
Your use case may not hit these limitations, in which case have at it! But many use cases will, especially the first and second limitations when attempting to restore a mysqldump backup. You may find you can work around the limitations by:
61+
62+
1. Running mysqldump with the `--extended-insert=FALSE` argument to insert every record using a separate statement to keep statements under 64 KB in size
63+
1. If your tables have foreign key constraints, you may be able to re-order them with children tables and records first, then parent tables and records second, allowing you to create tables and insert records without violating the constraints

query.ts

Lines changed: 194 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,36 @@ import {
3030
Keywords
3131
} from './types';
3232

33+
type REPLCallback = (err: Error | null, result?: string | Record<string, unknown>) => void
34+
35+
interface Statement {
36+
command: string,
37+
cb: REPLCallback
38+
}
39+
3340
import hydrateRecords from './hydrateRecords';
3441
import fetchKeywords from './fetchKeywords';
3542

36-
const DB_SELECT_COMMAND_RE = /^(?:use|\\c(?:onnect)?)\s+([^;\s]+);?$/i;
37-
const TX_BEGIN_COMMAND_RE = /^begin;$/i
38-
const TX_ROLLBACK_COMMAND_RE = /^rollback;$/i
39-
const TX_COMMIT_COMMAND_RE = /^commit;$/i
43+
const DB_SELECT_COMMAND_RE = /^\s*(?:use|\\c(?:onnect)?)\s+(`?)([^;\s]+)\1;?\s*$/i;
44+
const TX_BEGIN_COMMAND_RE = /^\s*begin\s*;$/i
45+
const TX_ROLLBACK_COMMAND_RE = /^\s*rollback\s*;$/i
46+
const TX_COMMIT_COMMAND_RE = /^\s*commit\s*;$/i
47+
const COMMENT_BEGIN_RE = /\/\*(?!!)/;
48+
const COMMENT_END_RE = /\*\//;
49+
const IN_COMMENT_END_RE = /(.|\n|\r)*?\*\//m;
50+
const START_COMMENT_RE = /\/\*(?!!)(.|\n|\r)*/m;
51+
const FULL_COMMENT_RE = /\/\*(?!!)(.|\n|\r)*?\*\//mg;
52+
53+
// Comments that start like like /*! or /*!57384 contain statements that *should* be executed by MySQL clients. See https://dev.mysql.com/doc/refman/en/comments.html.
54+
const FULL_NON_MYSQL_COMMENT_RE = /\/\*!\d*((?:.|\n|\r)*?)\*\//mg;
4055

4156
let keywords: Promise<Keywords>;
4257

58+
let currentStatementPromiseResolver: (value: Statement) => void;
59+
const statementPromises: Promise<Statement>[] = [
60+
new Promise(resolve => currentStatementPromiseResolver = resolve)
61+
];
62+
4363
export default async function query(awsConfig: AWSConfig, awsInfo: AWSInfo, clusterId: string, secretName: string, database?: string): Promise<void> {
4464
const resourceArn = `arn:${awsInfo.partition}:rds:${awsInfo.region}:${awsInfo.accountId}:cluster:${clusterId}`;
4565
const secretArn = `arn:${awsInfo.partition}:secretsmanager:${awsInfo.region}:${awsInfo.accountId}:secret:${secretName}`;
@@ -61,121 +81,49 @@ export default async function query(awsConfig: AWSConfig, awsInfo: AWSInfo, clus
6181

6282
keywords = fetchKeywords(rdsDataClient, resourceArn, secretArn, database);
6383

64-
let transactionId: string | undefined;
84+
// Start function to process statements sequentially
85+
processStatements(rdsDataClient, resourceArn, secretArn, database);
6586

66-
replStart({
67-
eval: async function (this: REPLServer, command: string, context: Context, file: string, cb: (err: Error | null, result: string | Record<string, unknown>) => void) {
68-
command = command.trim();
87+
let partial = '';
88+
let partialInComment = false;
6989

70-
const dbMatch = command.match(DB_SELECT_COMMAND_RE);
71-
if (dbMatch) {
72-
database = dbMatch[1];
73-
keywords = fetchKeywords(rdsDataClient, resourceArn, secretArn, database);
74-
cb(null, `Now using database ${database}`);
75-
return;
76-
}
77-
78-
if (TX_BEGIN_COMMAND_RE.test(command)) {
79-
if (transactionId) {
80-
cb(null, `Error: Transaction '${transactionId}' currently in progress, cannot create a new one`);
81-
return;
82-
}
90+
const replServer = replStart({
91+
eval: function (this: REPLServer, command: string, context: Context, file: string, cb: REPLCallback) {
92+
partial += command;
8393

84-
try {
85-
({ transactionId } = await rdsDataClient.send(
86-
new BeginTransactionCommand({
87-
resourceArn,
88-
secretArn,
89-
database
90-
})
91-
));
92-
cb(null, `Transaction '${transactionId}' begun`);
93-
} catch (err) {
94-
cb(null, `Failed to begin transaction: ${err.message} (${err.code})`);
95-
}
96-
97-
return;
94+
if (partialInComment && COMMENT_END_RE.test(partial)) {
95+
partial.replace(IN_COMMENT_END_RE, '');
96+
partialInComment = false;
9897
}
9998

100-
if (TX_ROLLBACK_COMMAND_RE.test(command)) {
101-
if (!transactionId) {
102-
cb(null, `Error: No transaction currently in progress`);
103-
return;
104-
}
105-
106-
try {
107-
await rdsDataClient.send(
108-
new RollbackTransactionCommand({
109-
resourceArn,
110-
secretArn,
111-
transactionId
112-
})
113-
);
114-
cb(null, `Transaction '${transactionId}' rolled back`);
115-
transactionId = undefined;
116-
} catch (err) {
117-
cb(null, `Failed to rollback transaction '${transactionId}': ${err.message} (${err.code})`);
118-
}
99+
partial = partial
100+
.replace(FULL_COMMENT_RE, '')
101+
.replace(FULL_NON_MYSQL_COMMENT_RE, '$1');
119102

120-
return;
103+
if (!partialInComment && COMMENT_BEGIN_RE.test(partial)) {
104+
partial.replace(START_COMMENT_RE, '');
105+
partialInComment = true;
121106
}
122107

123-
if (TX_COMMIT_COMMAND_RE.test(command)) {
124-
if (!transactionId) {
125-
cb(null, `Error: No transaction currently in progress`);
126-
return;
127-
}
108+
const statements = partial.split(';');
128109

129-
try {
130-
await rdsDataClient.send(
131-
new CommitTransactionCommand({
132-
resourceArn,
133-
secretArn,
134-
transactionId
135-
})
136-
);
137-
cb(null, `Transaction '${transactionId}' committed`);
138-
transactionId = undefined;
139-
} catch (err) {
140-
cb(null, `Failed to commit transaction '${transactionId}': ${err.message} (${err.code})`);
141-
}
110+
partial = statements.pop() as string;
142111

143-
return;
112+
// `use <database>` is a command and doesn't require a semicolon terminator, check for it specially
113+
if (DB_SELECT_COMMAND_RE.test(partial)) {
114+
statements.push(partial);
115+
partial = '';
144116
}
145117

146-
let records: Field[][] | undefined;
147-
let columnMetadata: ColumnMetadata[] | undefined;
148-
let numberOfRecordsUpdated;
149-
try {
150-
({ records, columnMetadata, numberOfRecordsUpdated } = await rdsDataClient.send(
151-
new ExecuteStatementCommand({
152-
resourceArn,
153-
secretArn,
154-
database,
155-
sql: command,
156-
includeResultMetadata: true,
157-
transactionId
158-
})
159-
));
160-
} catch (err) {
161-
cb(null, `Failed to execute statement: ${err.message} (${err.code})`);
162-
return;
163-
}
164-
165-
const output: {
166-
Records?: Row[]
167-
'Record Count'?: number
168-
'Number of Affected Records'?: number
169-
} = {};
170-
171-
if (records) {
172-
output.Records = hydrateRecords(records, columnMetadata as ColumnMetadata[]);
173-
output['Record Count'] = output.Records.length
174-
} else if (typeof numberOfRecordsUpdated === 'number') {
175-
output['Number of Affected Records'] = numberOfRecordsUpdated
118+
for (const statement of statements) {
119+
// Enqueue command to run statements sequentially
120+
const resolver = currentStatementPromiseResolver;
121+
statementPromises.push(new Promise(resolve => currentStatementPromiseResolver = resolve));
122+
resolver({
123+
command: statement,
124+
cb
125+
});
176126
}
177-
178-
cb(null, output);
179127
},
180128

181129
// Loosely based on the mysql CLI completion algorithm
@@ -224,6 +172,145 @@ export default async function query(awsConfig: AWSConfig, awsInfo: AWSInfo, clus
224172
callback(err);
225173
}
226174
},
227-
writer: prettyoutput
175+
writer: prettyoutput,
176+
preview: true
228177
});
178+
179+
replServer.on('reset', () => {
180+
partial = '';
181+
partialInComment = false;
182+
});
183+
}
184+
185+
function handleError (cb: REPLCallback, message: string) {
186+
if (process.stdin.isTTY) {
187+
cb(null, message);
188+
} else {
189+
console.error(message);
190+
process.exit(1);
191+
}
192+
}
193+
194+
async function processStatements (rdsDataClient: RDSDataClient, resourceArn: string, secretArn: string, database?: string) {
195+
let transactionId: string | undefined;
196+
197+
for await (const { command, cb } of statementPromises) {
198+
if (/^\s*$/.test(command)) {
199+
cb(null);
200+
continue;
201+
}
202+
203+
const dbMatch = command.match(DB_SELECT_COMMAND_RE);
204+
if (dbMatch) {
205+
database = dbMatch[2];
206+
keywords = fetchKeywords(rdsDataClient, resourceArn, secretArn, database);
207+
cb(null, `Now using database ${database}`);
208+
continue;
209+
}
210+
211+
if (TX_BEGIN_COMMAND_RE.test(command)) {
212+
if (transactionId) {
213+
handleError(cb, `Error: Transaction '${transactionId}' currently in progress, cannot create a new one`);
214+
continue;
215+
}
216+
217+
try {
218+
({ transactionId } = await rdsDataClient.send(
219+
new BeginTransactionCommand({
220+
resourceArn,
221+
secretArn,
222+
database
223+
})
224+
));
225+
cb(null, `Transaction '${transactionId}' begun`);
226+
} catch (err) {
227+
handleError(cb, `Failed to begin transaction: ${err.message} (${err.code})`);
228+
}
229+
230+
continue;
231+
}
232+
233+
if (TX_ROLLBACK_COMMAND_RE.test(command)) {
234+
if (!transactionId) {
235+
cb(null, `Error: No transaction currently in progress`);
236+
continue;
237+
}
238+
239+
try {
240+
await rdsDataClient.send(
241+
new RollbackTransactionCommand({
242+
resourceArn,
243+
secretArn,
244+
transactionId
245+
})
246+
);
247+
cb(null, `Transaction '${transactionId}' rolled back`);
248+
transactionId = undefined;
249+
} catch (err) {
250+
handleError(cb, `Failed to rollback transaction '${transactionId}': ${err.message} (${err.code})`);
251+
}
252+
253+
continue;
254+
}
255+
256+
if (TX_COMMIT_COMMAND_RE.test(command)) {
257+
if (!transactionId) {
258+
handleError(cb, `Error: No transaction currently in progress`);
259+
continue;
260+
}
261+
262+
try {
263+
await rdsDataClient.send(
264+
new CommitTransactionCommand({
265+
resourceArn,
266+
secretArn,
267+
transactionId
268+
})
269+
);
270+
cb(null, `Transaction '${transactionId}' committed`);
271+
transactionId = undefined;
272+
} catch (err) {
273+
handleError(cb, `Failed to commit transaction '${transactionId}': ${err.message} (${err.code})`);
274+
}
275+
276+
continue;
277+
}
278+
279+
let records: Field[][] | undefined;
280+
let columnMetadata: ColumnMetadata[] | undefined;
281+
let numberOfRecordsUpdated;
282+
try {
283+
({ records, columnMetadata, numberOfRecordsUpdated } = await rdsDataClient.send(
284+
new ExecuteStatementCommand({
285+
resourceArn,
286+
secretArn,
287+
database,
288+
sql: command,
289+
includeResultMetadata: true,
290+
transactionId
291+
})
292+
));
293+
} catch (err) {
294+
handleError(cb, `Failed to execute statement: ${err.message} (${err.code})`);
295+
continue;
296+
}
297+
298+
const output: {
299+
Records?: Row[]
300+
'Record Count'?: number
301+
'Statement': string
302+
'Number of Affected Records'?: number
303+
} = {
304+
Statement: command
305+
};
306+
307+
if (records) {
308+
output.Records = hydrateRecords(records, columnMetadata as ColumnMetadata[]);
309+
output['Record Count'] = output.Records.length
310+
} else if (typeof numberOfRecordsUpdated === 'number') {
311+
output['Number of Affected Records'] = numberOfRecordsUpdated
312+
}
313+
314+
cb(null, output);
315+
}
229316
}

0 commit comments

Comments
 (0)