Skip to content

Commit 0d16c94

Browse files
committed
fix(cli): import data errors with strings, nulls
1 parent e4de8e3 commit 0d16c94

File tree

3 files changed

+225
-19
lines changed

3 files changed

+225
-19
lines changed

packages/cli/src/commands/import-data.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Arguments } from "yargs";
33
import chalk from "chalk";
44
import { type Wallet } from "ethers";
55
import { studioAliases } from "@tableland/studio-client";
6-
import { Database } from "@tableland/sdk";
6+
import { Database, Validator, helpers as SdkHelpers } from "@tableland/sdk";
77
import { type GlobalOptions } from "../cli.js";
88
import {
99
ERROR_INVALID_STORE_PATH,
@@ -12,12 +12,14 @@ import {
1212
logger,
1313
normalizePrivateKey,
1414
parseCsvFile,
15+
wrapText,
1516
FileStore,
1617
} from "../utils.js";
1718

18-
// note: abnormal spacing is needed to ensure help message is formatted correctly
19-
export const command = "import-data <definition> <file>";
20-
export const desc = "write the content of a csv into an existing table";
19+
export const command = wrapText("import-data <definition> <file>");
20+
export const desc = wrapText(
21+
"write the content of a csv into an existing table",
22+
);
2123

2224
export const handler = async (
2325
argv: Arguments<GlobalOptions>,
@@ -46,6 +48,7 @@ export const handler = async (
4648
(await aliases.read())[definition],
4749
"could not find definition in project",
4850
);
51+
const { chainId, tableId } = await SdkHelpers.validateTableName(tableName);
4952

5053
// need to reverse lookup tableName from definition and projectId so
5154
// that the wallet can be connected to the right provider
@@ -61,6 +64,13 @@ export const handler = async (
6164
signer,
6265
aliases,
6366
});
67+
const baseUrl = SdkHelpers.getBaseUrl(31337);
68+
const val = new Validator({ baseUrl });
69+
// get the table schema to help map values to their type
70+
const { schema } = await val.getTableById({
71+
chainId,
72+
tableId: tableId.toString(),
73+
});
6474

6575
const fileString = readFileSync(file).toString();
6676
const dataObject = await parseCsvFile(fileString);
@@ -71,7 +81,7 @@ export const handler = async (
7181
// need to capture row length now since `batchRows` will mutate the
7282
// rows Array to reduce memory overhead
7383
const rowCount = Number(rows.length);
74-
const statements = csvHelp.batchRows(rows, headers, definition);
84+
const statements = csvHelp.batchRows(rows, headers, schema, definition);
7585

7686
const doImport = await confirmImport({
7787
statements,
@@ -82,21 +92,26 @@ export const handler = async (
8292

8393
if (!doImport) return logger.log("aborting");
8494

85-
const results = await db.batch(statements.map((stmt) => db.prepare(stmt)));
95+
const stmts = statements.map((stmt) => db.prepare(stmt));
96+
const results = await db.batch(stmts);
8697
// the batch method returns an array of results for reads, but in this case
8798
// its an Array of length 1 with a single Object containing txn data
8899
const result = results[0];
89-
90-
logger.log(
91-
`successfully inserted ${rowCount} row${
92-
rowCount === 1 ? "" : "s"
93-
} into ${definition}
100+
const rec = await result.meta.txn?.wait();
101+
if (rec?.errorEventIdx !== undefined) {
102+
logger.error(rec);
103+
} else {
104+
logger.log(
105+
`successfully inserted ${rowCount} row${
106+
rowCount === 1 ? "" : "s"
107+
} into ${definition}
94108
transaction receipt: ${chalk.gray.bold(
95109
JSON.stringify(result.meta?.txn, null, 4),
96110
)}
97111
project id: ${projectId}
98112
environment id: ${environmentId}`,
99-
);
113+
);
114+
}
100115
} catch (err: any) {
101116
logger.error(err);
102117
}
@@ -129,10 +144,10 @@ async function confirmImport(info: {
129144
)} to insert ${chalk.yellow(info.rowCount)} row${
130145
info.rowCount === 1 ? "" : "s"
131146
} into table ${chalk.yellow(info.tableName)}
132-
This can be done with a total of ${chalk.yellow(statementCount)} statment${
147+
This can be done with a total of ${chalk.yellow(statementCount)} statement${
133148
statementCount === 1 ? "" : "s"
134149
}
135-
The total size of the statment${
150+
The total size of the statement${
136151
statementCount === 1 ? "" : "s"
137152
} is: ${chalk.yellow(statementLength)}
138153
The estimated cost is ${cost}

packages/cli/src/utils.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "ethers";
1212
import { z } from "zod";
1313
import createKeccakHash from "keccak";
14-
import { helpers as sdkHelpers } from "@tableland/sdk";
14+
import { helpers as sdkHelpers, type Schema } from "@tableland/sdk";
1515
import { init } from "@tableland/sqlparser";
1616
import { type API, type ClientConfig, api } from "@tableland/studio-client";
1717

@@ -158,16 +158,31 @@ export const csvHelp = {
158158
batchRows: function (
159159
rows: Array<Array<string | number>>,
160160
headers: string[],
161+
schema: Schema,
161162
table: string,
162163
) {
163164
let rowCount = 0;
164165
const batches = [];
165166
while (rows.length) {
166167
let statement = `INSERT INTO ${table}(${headers.join(",")})VALUES`;
168+
// map headers to schema columns to add in the header index
169+
const schemaWithIdx = headers.map((header, idx) => {
170+
const colInfo = schema.columns.find((col) => {
171+
// we cannot guarantee that an imported table will have backticks
172+
// around the column name, so we need to check for both
173+
return col.name === header || col.name === header.replace(/`/g, "");
174+
});
175+
return {
176+
idx,
177+
name: colInfo?.name,
178+
type: colInfo?.type,
179+
};
180+
});
167181

168182
while (
169183
rows.length &&
170-
byteSize(statement + getNextValues(rows[0])) < MAX_STATEMENT_SIZE
184+
byteSize(statement + getNextValues(rows[0], schemaWithIdx)) <
185+
MAX_STATEMENT_SIZE
171186
) {
172187
const row = rows.shift();
173188
if (!row) continue;
@@ -178,7 +193,7 @@ export const csvHelp = {
178193
);
179194
}
180195
// include comma between
181-
statement += getNextValues(row);
196+
statement += getNextValues(row, schemaWithIdx);
182197
}
183198

184199
// remove last comma and add semicolon
@@ -209,8 +224,22 @@ const byteSize = function (str: string) {
209224
return new Blob([str]).size;
210225
};
211226

212-
const getNextValues = function (row: Array<string | number>) {
213-
return `(${row.join(",")}),`;
227+
const getNextValues = function (
228+
row: Array<string | number>,
229+
schemaWithIdx: Array<Record<string, any>>,
230+
) {
231+
// wrap values in single quotes if the `type` is not `int` nor `integer`
232+
const rowFormatted = row.map((val, idx) => {
233+
const colInfo = schemaWithIdx.find((col) => {
234+
return col.idx === idx;
235+
});
236+
const type = colInfo?.type;
237+
// empty csv values must be treated as `NULL` to avoid SQL string errors
238+
if (typeof val === "string" && val.length === 0) return "NULL";
239+
if (type === "int" || type === "integer") return val;
240+
return `'${val}'`;
241+
});
242+
return `(${rowFormatted.join(",")}),`;
214243
};
215244

216245
function getIdFromTableName(tableName: string, revIndx: number) {
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import path from "path";
2+
import { fileURLToPath } from "url";
3+
import { equal, match } from "node:assert";
4+
import { getAccounts, getDatabase } from "@tableland/local";
5+
import { afterEach, before, describe, test } from "mocha";
6+
import { restore, spy } from "sinon";
7+
import { temporaryWrite } from "tempy";
8+
import mockStd from "mock-stdin";
9+
import yargs from "yargs/yargs";
10+
import * as modImportTable from "../src/commands/import-table.js";
11+
import type { CommandOptions as ImportTableCommandOptions } from "../src/commands/import-table.js";
12+
import * as mod from "../src/commands/import-data.js";
13+
import { type GlobalOptions } from "../src/cli.js";
14+
import { logger, wait } from "../src/utils.js";
15+
import {
16+
TEST_TIMEOUT_FACTOR,
17+
TEST_API_BASE_URL,
18+
TEST_REGISTRY_PORT,
19+
TEST_PROJECT_ID,
20+
} from "./utils.js";
21+
22+
const _dirname = path.dirname(fileURLToPath(import.meta.url));
23+
const accounts = getAccounts(`http://127.0.0.1:${TEST_REGISTRY_PORT}`);
24+
const account = accounts[10];
25+
const db = getDatabase(account);
26+
27+
const defaultArgs = [
28+
"--store",
29+
path.join(_dirname, ".studioclisession.json"),
30+
"--privateKey",
31+
account.privateKey.slice(2),
32+
"--chain",
33+
"local-tableland",
34+
"--providerUrl",
35+
`http://127.0.0.1:${TEST_REGISTRY_PORT}/`,
36+
"--apiUrl",
37+
TEST_API_BASE_URL,
38+
"--projectId",
39+
TEST_PROJECT_ID,
40+
];
41+
42+
describe("commands/import-data", function () {
43+
this.timeout(30000 * TEST_TIMEOUT_FACTOR);
44+
45+
let table1: string;
46+
let table2: string;
47+
const defName1 = "data_import_1";
48+
const defName2 = "data_import_2";
49+
const desc = "table description";
50+
51+
before(async function () {
52+
const batch = await db.batch([
53+
// use no backticks vs. including them to emulate a non-Studio vs. Studio
54+
// created table's column names (ensure csv header/col type parsing works)
55+
db.prepare(`create table ${defName1} (id int, val text);`),
56+
db.prepare(`create table ${defName2} (\`id\` int, \`val\` text);`),
57+
]);
58+
const res = await batch[0].meta.txn?.wait();
59+
const tableNames = res?.names ?? [];
60+
table1 = tableNames[0];
61+
table2 = tableNames[1];
62+
63+
await yargs(["import", "table", table1, desc, ...defaultArgs])
64+
.command<ImportTableCommandOptions>(modImportTable)
65+
.parse();
66+
await yargs(["import", "table", table2, desc, ...defaultArgs])
67+
.command<ImportTableCommandOptions>(modImportTable)
68+
.parse();
69+
70+
await wait(1000);
71+
});
72+
73+
afterEach(function () {
74+
restore();
75+
});
76+
77+
test("can import with all values included", async function () {
78+
const csvStr = `id,val\n1,test_value`;
79+
const csvFile = await temporaryWrite(csvStr, { extension: "csv" });
80+
81+
const consoleLog = spy(logger, "log");
82+
const stdin = mockStd.stdin();
83+
setTimeout(() => {
84+
stdin.send("y\n");
85+
}, 1000);
86+
await yargs(["import-data", defName1, csvFile, ...defaultArgs])
87+
.command<GlobalOptions>(mod)
88+
.parse();
89+
90+
const res = consoleLog.getCall(0).firstArg;
91+
const successRes = res.match(/^(.*)$/m)[1];
92+
93+
equal(successRes, `successfully inserted 1 row into ${defName1}`);
94+
});
95+
96+
test("can import with empty row values", async function () {
97+
const csvStr = `id,val\n1,\n,test_value\n`;
98+
const csvFile = await temporaryWrite(csvStr, { extension: "csv" });
99+
100+
const consoleLog = spy(logger, "log");
101+
const stdin = mockStd.stdin();
102+
setTimeout(() => {
103+
stdin.send("y\n");
104+
}, 1000);
105+
await yargs(["import-data", defName2, csvFile, ...defaultArgs])
106+
.command<GlobalOptions>(mod)
107+
.parse();
108+
109+
const res = consoleLog.getCall(0).firstArg;
110+
const successRes = res.match(/^(.*)$/m)[1];
111+
112+
equal(successRes, `successfully inserted 2 rows into ${defName2}`);
113+
});
114+
115+
test("fails with wrong headers", async function () {
116+
const csvStr = `not_id,not_val\n1,test_value\n`;
117+
const csvFile = await temporaryWrite(csvStr, { extension: "csv" });
118+
119+
const consoleError = spy(logger, "error");
120+
const stdin = mockStd.stdin();
121+
setTimeout(() => {
122+
stdin.send("y\n");
123+
}, 1000);
124+
await yargs(["import-data", defName2, csvFile, ...defaultArgs])
125+
.command<GlobalOptions>(mod)
126+
.parse();
127+
128+
const res = consoleError.getCall(0).firstArg;
129+
const regex = new RegExp(`table ${table2} has no column named not_id`);
130+
match(res.toString(), regex);
131+
});
132+
133+
test("fails with mismatched header and row length", async function () {
134+
let csvStr = `id\n1,test_value\n`;
135+
let csvFile = await temporaryWrite(csvStr, { extension: "csv" });
136+
137+
const consoleError = spy(logger, "error");
138+
const stdin = mockStd.stdin();
139+
setTimeout(() => {
140+
stdin.send("y\n");
141+
}, 1000);
142+
await yargs(["import-data", defName2, csvFile, ...defaultArgs])
143+
.command<GlobalOptions>(mod)
144+
.parse();
145+
146+
let res = consoleError.getCall(0).firstArg;
147+
const regex = /Invalid Record Length/;
148+
match(res.toString(), regex);
149+
150+
csvStr = `id,val\n1\n`;
151+
csvFile = await temporaryWrite(csvStr, { extension: "csv" });
152+
setTimeout(() => {
153+
stdin.send("y\n");
154+
}, 1000);
155+
await yargs(["import-data", defName2, csvFile, ...defaultArgs])
156+
.command<GlobalOptions>(mod)
157+
.parse();
158+
159+
res = consoleError.getCall(0).firstArg;
160+
match(res.toString(), regex);
161+
});
162+
});

0 commit comments

Comments
 (0)