Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add file upload #20

Merged
merged 10 commits into from
Oct 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/graphql-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"graphql": "^16.7.1",
"graphql-upload": "13",
"mysql2": "^3.1.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.5.4",
Expand Down
18 changes: 18 additions & 0 deletions packages/graphql-server/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,24 @@ type Mutation {
deleteBranch(databaseName: String!, branchName: String!): Boolean!
addDatabaseConnection(url: String, useEnv: Boolean): String!
createDatabase(databaseName: String!): Boolean!
loadDataFile(tableName: String!, refName: String!, databaseName: String!, importOp: ImportOperation!, fileType: FileType!, file: Upload!, modifier: LoadDataModifier): Boolean!
createTag(tagName: String!, databaseName: String!, message: String, fromRefName: String!): Tag!
deleteTag(databaseName: String!, tagName: String!): Boolean!
}

enum ImportOperation {
Update
}

enum FileType {
Csv
Psv
}

"""The `Upload` scalar type represents a file upload."""
scalar Upload

enum LoadDataModifier {
Ignore
Replace
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { DataSourceService } from "./dataSource.service";
providers: [
{
provide: DataSourceService,
useValue: new DataSourceService(undefined),
useValue: new DataSourceService(undefined, undefined),
},
],
exports: [DataSourceService],
Expand Down
26 changes: 24 additions & 2 deletions packages/graphql-server/src/dataSources/dataSource.service.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import { Injectable } from "@nestjs/common";
import * as mysql from "mysql2/promise";
import { DataSource, QueryRunner } from "typeorm";
import { RawRows } from "../utils/commonTypes";

export const dbNotFoundErr = "Database connection not found";
export type ParQuery = (q: string, p?: any[] | undefined) => Promise<RawRows>;

Check warning on line 7 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

@Injectable()
export class DataSourceService {
constructor(private ds: DataSource | undefined) {}
constructor(
private ds: DataSource | undefined,
private mysqlConfig: mysql.ConnectionOptions | undefined, // Used for file upload
) {}

getDS(): DataSource {
const { ds } = this;
if (!ds) throw new Error(dbNotFoundErr);
return ds;
}

getMySQLConfig(): mysql.ConnectionOptions {
const { mysqlConfig } = this;
if (!mysqlConfig) throw new Error("MySQL config not found");
return mysqlConfig;
}

getQR(): QueryRunner {
return this.getDS().createQueryRunner();
}

async handleAsyncQuery(
work: (qr: QueryRunner) => Promise<any>,

Check warning on line 33 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
): Promise<RawRows> {
const qr = this.getQR();
try {
Expand All @@ -34,12 +44,12 @@

// Assumes Dolt database
async query(
executeQuery: (pq: ParQuery) => any,

Check warning on line 47 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
dbName?: string,
refName?: string,
): Promise<any> {

Check warning on line 50 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
return this.handleAsyncQuery(async qr => {
async function query(q: string, p?: any[] | undefined): Promise<RawRows> {

Check warning on line 52 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const res = await qr.query(q, p);
return res;
}
Expand All @@ -54,12 +64,12 @@

// Queries that will work on both MySQL and Dolt
async queryMaybeDolt(
executeQuery: (pq: ParQuery, isDolt: boolean) => any,

Check warning on line 67 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
dbName?: string,
refName?: string,
): Promise<any> {

Check warning on line 70 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
return this.handleAsyncQuery(async qr => {
async function query(q: string, p?: any[] | undefined): Promise<RawRows> {

Check warning on line 72 in packages/graphql-server/src/dataSources/dataSource.service.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const res = await qr.query(q, p);
return res;
}
Expand Down Expand Up @@ -94,13 +104,25 @@
},
});

this.mysqlConfig = {
uri: connUrl,
ssl: {
rejectUnauthorized: false,
},
connectionLimit: 1,
dateStrings: ["DATE"],

// Allows file upload via LOAD DATA
flags: ["+LOCAL_FILES"],
};

await this.ds.initialize();
}
}

// Cannot use params here for the database revision. It will incorrectly
// escape refs with dots
function useDBStatement(
export function useDBStatement(
dbName?: string,
refName?: string,
isDolt = true,
Expand Down
5 changes: 5 additions & 0 deletions packages/graphql-server/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { NestFactory } from "@nestjs/core";
import * as cookieParser from "cookie-parser";
import * as cors from "cors";
import { graphqlUploadExpress } from "graphql-upload";
import { AppModule } from "./app.module";

const oneMB = 1024 * 1024;
const maxFileSize = 400 * oneMB;

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
app.use(graphqlUploadExpress({ maxFileSize, maxFiles: 1 }));
if (process.env.NODE_ENV === "development") {
app.use(
"/graphql",
Expand All @@ -17,4 +22,4 @@
}
await app.listen(9002);
}
bootstrap().catch(e => console.error("something went wrong", e));

Check warning on line 25 in packages/graphql-server/src/main.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
2 changes: 2 additions & 0 deletions packages/graphql-server/src/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { RowResolver } from "./rows/row.resolver";
import { SqlSelectResolver } from "./sqlSelects/sqlSelect.resolver";
import { StatusResolver } from "./status/status.resolver";
import { TableResolver } from "./tables/table.resolver";
import { FileUploadResolver } from "./tables/upload.resolver";
import { TagResolver } from "./tags/tag.resolver";

const resolvers = [
BranchResolver,
CommitResolver,
DatabaseResolver,
DocsResolver,
FileUploadResolver,
RowResolver,
SqlSelectResolver,
StatusResolver,
Expand Down
28 changes: 28 additions & 0 deletions packages/graphql-server/src/tables/table.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { registerEnumType } from "@nestjs/graphql";

export enum ImportOperation {
// Create,
// Overwrite,
Update,
// Replace,
}

registerEnumType(ImportOperation, { name: "ImportOperation" });

export enum FileType {
Csv,
Psv,
// Xlsx,
// Json,
// Sql,
// Any,
}

registerEnumType(FileType, { name: "FileType" });

export enum LoadDataModifier {
Ignore,
Replace,
}

registerEnumType(LoadDataModifier, { name: "LoadDataModifier" });
31 changes: 31 additions & 0 deletions packages/graphql-server/src/tables/table.queries.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FileType, LoadDataModifier } from "./table.enum";

export const indexQuery = `SELECT
table_name,
index_name,
Expand All @@ -13,3 +15,32 @@ export const foreignKeysQuery = `SELECT * FROM INFORMATION_SCHEMA.KEY_COLUMN_USA
export const columnsQuery = `DESCRIBE ??`;

export const listTablesQuery = `SHOW FULL TABLES WHERE table_type = 'BASE TABLE'`;

export const getLoadDataQuery = (
filename: string,
tableName: string,
fileType: FileType,
modifier?: LoadDataModifier,
): string => `LOAD DATA LOCAL INFILE '${filename}'
${getModifier(modifier)}INTO TABLE \`${tableName}\`
FIELDS TERMINATED BY '${getDelim(fileType)}' ENCLOSED BY ''
LINES TERMINATED BY '\n'
IGNORE 1 ROWS;`;

function getModifier(m?: LoadDataModifier): string {
switch (m) {
case LoadDataModifier.Ignore:
return "IGNORE ";
case LoadDataModifier.Replace:
return "REPLACE ";
default:
return "";
}
}

function getDelim(ft: FileType): string {
if (ft === FileType.Psv) {
return "|";
}
return ",";
}
71 changes: 71 additions & 0 deletions packages/graphql-server/src/tables/upload.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Args, ArgsType, Field, Mutation, Resolver } from "@nestjs/graphql";
import { ReadStream } from "fs";
import { GraphQLUpload } from "graphql-upload";
import * as mysql from "mysql2/promise";
import {
DataSourceService,
useDBStatement,
} from "../dataSources/dataSource.service";
import { TableArgs } from "../utils/commonTypes";
import { FileType, ImportOperation, LoadDataModifier } from "./table.enum";
import { Table } from "./table.model";
import { getLoadDataQuery } from "./table.queries";

export interface FileUpload {
filename: string;
mimetype: string;
encoding: string;
createReadStream: () => ReadStream;
}

@ArgsType()
class TableImportArgs extends TableArgs {
@Field(_type => ImportOperation)
importOp: ImportOperation;

@Field(_type => FileType)
fileType: FileType;

@Field(() => GraphQLUpload)
file: Promise<FileUpload>;

@Field(_type => LoadDataModifier, { nullable: true })
modifier?: LoadDataModifier;
}

@Resolver(_of => Table)
export class FileUploadResolver {
constructor(private readonly dss: DataSourceService) {}

@Mutation(_returns => Boolean)
async loadDataFile(@Args() args: TableImportArgs): Promise<boolean> {
const conn = await mysql.createConnection(this.dss.getMySQLConfig());

let isDolt = false;
try {
const res = await conn.query("SELECT dolt_version()");
isDolt = !!res;
} catch (_) {
// ignore
}

await conn.query(useDBStatement(args.databaseName, args.refName, isDolt));
await conn.query("SET GLOBAL local_infile=ON;");

const { createReadStream, filename } = await args.file;

await conn.query({
sql: getLoadDataQuery(
filename,
args.tableName,
args.fileType,
args.modifier,
),
infileStreamFactory: createReadStream,
});

conn.destroy();

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ export default function DoltDisabledSelector(props: Props) {
<div
className={css.doltDisabled}
data-tooltip-content="Use Dolt to enable branches"
data-tooltip-id="branch-selector-no-dolt"
data-tooltip-id="selector-no-dolt"
data-tooltip-place="top"
>
{props.val}
</div>
<Tooltip id="branch-selector-no-dolt" />
<Tooltip id="selector-no-dolt" />
</>
);
}
44 changes: 44 additions & 0 deletions packages/web/components/CustomRadio/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.container {
@apply block relative pl-8 mb-2 text-primary font-semibold cursor-pointer select-none;

input {
@apply absolute opacity-0;
}
}

.disabled {
@apply text-ld-darkgrey cursor-default;
}

.checkmark {
@apply absolute top-0 left-0 bg-white rounded-full mt-1 border border-primary w-4 h-4;
}

.container:hover input ~ .checkmark {
@apply border-acc-linkblue;
}

.container input:checked ~ .checkmark {
@apply bg-white;
}
.container input:focus ~ .checkmark {
@apply widget-shadow-lightblue;
}

.container input:disabled ~ .checkmark,
.container:hover input:disabled ~ .checkmark {
@apply border-ld-darkgrey;
}

.checkmark:after {
@apply absolute hidden;
content: "";
}

.container .checkmark:after {
@apply rounded-full bg-white top-[3px] left-[3px] w-2 h-2;
}

.container input:checked ~ .checkmark:after {
@apply block bg-primary;
}
50 changes: 50 additions & 0 deletions packages/web/components/CustomRadio/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { setup } from "@lib/testUtils.test";
import { screen } from "@testing-library/react";
import CustomRadio from ".";

describe("test CustomCheckbox", () => {
const mocks = [
{ name: "one", label: "one-label" },
{ name: "two", label: "two-label" },
{ name: "three", label: "three-label" },
];

mocks.forEach((mock, ind) => {
it(`renders CustomRadio for of label ${mock.label}`, async () => {
const checked = ind % 2 === 0;
const disabled = ind === 2;
const onChangeValue = jest.fn();

const { user } = setup(
<CustomRadio
{...mock}
onChange={onChangeValue}
checked={checked}
className="classname"
disabled={disabled}
>
{mock.label}
</CustomRadio>,
);
const content = screen.getByLabelText(mock.label);
expect(content).toBeVisible();
if (!disabled) {
const input = screen.getByRole("radio");
if (checked) {
expect(input).toBeChecked();
} else {
expect(input).not.toBeChecked();
}

await user.click(screen.getByLabelText(mock.label));
if (checked) {
expect(onChangeValue).not.toHaveBeenCalled();
} else {
expect(onChangeValue).toHaveBeenCalled();
}
} else {
expect(content).toBeDisabled();
}
});
});
});
Loading
Loading