Skip to content

Commit eade79c

Browse files
author
Sylvestre
authored
Add MSSQL DB client (#37)
undefined
1 parent 7563330 commit eade79c

15 files changed

+4413
-1089
lines changed

.babelrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"presets": ["@babel/preset-env"]
3+
}

.env.test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
NODE_ENV=test
2+
MSSQL_CREDENTIALS:Server=mssql,1433;Database=master;User Id=sa;Password=Pass@word;trustServerCertificate=true;
3+
MSSQL_CREDENTIALS_READ_ONLY:Server=mssql,1433;Database=master;User Id=reader;Password=re@derP@ssw0rd;trustServerCertificate=true;

.env.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
2+
export const MSSQL_CREDENTIALS = env("MSSQL_CREDENTIALS");
3+
export const MSSQL_CREDENTIALS_READ_ONLY = env("MSSQL_CREDENTIALS_READ_ONLY");
4+
export const NODE_ENV = env("NODE_ENV");
5+
6+
function env(key, defaultValue) {
7+
const value = process.env[key]; // eslint-disable-line no-process-env
8+
if (value !== undefined) return value;
9+
if (defaultValue !== undefined) return defaultValue;
10+
throw new Error(`Missing environment variable: ${key}`);
11+
}

.mocharc.cjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
color: true,
3+
extension: ["js"],
4+
global: [],
5+
ignore: [],
6+
require: "@babel/register",
7+
spec: ["test/**/*.test.js"],
8+
};

Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM node:16.17.0-alpine
2+
3+
RUN mkdir /app
4+
WORKDIR /app
5+
6+
RUN apk --no-cache add bash
7+
8+
COPY package.json yarn.lock /app/
9+
RUN \
10+
yarn --frozen-lockfile && \
11+
yarn cache clean
12+
13+
ENV PATH="/app/node_modules/.bin:${PATH}"
14+
15+
COPY . /app/
16+
17+
CMD yarn test

data/AdventureWorks2019.bak

8.12 MB
Binary file not shown.

data/seed.mssql.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import mssql from "mssql";
2+
import {MSSQL_CREDENTIALS} from "../.env.test.js";
3+
4+
const credentials = MSSQL_CREDENTIALS;
5+
6+
const seed = async () => {
7+
await mssql.connect(credentials);
8+
9+
await mssql.query`RESTORE DATABASE test
10+
FROM DISK = '/var/opt/mssql/backup/test.bak'
11+
WITH REPLACE, RECOVERY,
12+
MOVE 'AdventureWorksLT2012_Data' TO '/var/opt/mssql/data/aw2019.mdf',
13+
MOVE 'AdventureWorksLT2012_Log' TO '/var/opt/mssql/data/aw2019.ldf';`;
14+
15+
await mssql.query`IF NOT EXISTS(SELECT name
16+
FROM sys.syslogins
17+
WHERE name='reader')
18+
BEGIN
19+
CREATE LOGIN reader WITH PASSWORD = 're@derP@ssw0rd'
20+
CREATE USER reader FOR LOGIN reader
21+
END`;
22+
};
23+
24+
seed()
25+
.then(() => {
26+
console.log(`MS_SQL DB seeded.`);
27+
process.exit(0);
28+
})
29+
.catch((err) => {
30+
console.error(err.message, err);
31+
process.exit(1);
32+
});

docker-compose.local.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
version: "3.7"
2+
3+
services:
4+
mssql:
5+
image: mcr.microsoft.com/azure-sql-edge
6+
expose:
7+
- "1433"

docker-compose.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
version: "3.7"
2+
3+
services:
4+
test:
5+
build: .
6+
depends_on:
7+
- mssql
8+
env_file:
9+
- .env.test
10+
networks:
11+
- db_proxy_test
12+
command: sh -c "set -o pipefail && wait-on -d 10000 -t 30000 tcp:mssql:1433 && node ./data/seed.mssql.js && TZ=UTC NODE_ENV=TEST node_modules/.bin/mocha"
13+
14+
mssql:
15+
image: mcr.microsoft.com/mssql/server:2019-latest
16+
environment:
17+
- MSSQL_SA_PASSWORD=Pass@word
18+
- ACCEPT_EULA=Y
19+
- MSSQL_DATABASE=test
20+
- MSSQL_SLEEP=7
21+
volumes:
22+
- ./data/AdventureWorks2019.bak:/var/opt/mssql/backup/test.bak
23+
ports:
24+
- "1433:1433"
25+
networks:
26+
- db_proxy_test
27+
28+
networks:
29+
db_proxy_test:
30+
name: db_proxy_test

lib/errors.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import {createError} from "micro";
2-
export const unauthorized = error => createError(401, "Unauthorized", error);
3-
export const notFound = error => createError(404, "Not Found", error);
4-
export const exit = message => {
2+
export const unauthorized = (error) => createError(401, "Unauthorized", error);
3+
export const notFound = (error) => createError(404, "Not Found", error);
4+
export const notImplemented = (error) =>
5+
createError(501, "Not Implemented", error);
6+
export const badRequest = (error) =>
7+
createError(400, typeof error === "string" ? error : "Bad request", error);
8+
export const failedCheck = (error) =>
9+
createError(200, typeof error === "string" ? error : "Failed check", error);
10+
export const exit = (message) => {
511
console.error(message); // eslint-disable-line no-console
612
process.exit(1);
713
};

lib/mssql.js

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import Ajv from "ajv";
2+
import JSONStream from "JSONStream";
3+
import {json} from "micro";
4+
import mssql from "mssql";
5+
import {failedCheck, badRequest, notImplemented} from "./errors.js";
6+
7+
const TYPES = mssql.TYPES;
8+
9+
const pools = new Map();
10+
11+
const READ_ONLY = new Set(["SELECT", "USAGE"]);
12+
13+
const ajv = new Ajv();
14+
const validate = ajv.compile({
15+
type: "object",
16+
additionalProperties: false,
17+
required: ["sql"],
18+
properties: {
19+
sql: {type: "string", minLength: 1},
20+
params: {type: "array"},
21+
},
22+
});
23+
24+
// See: https://tediousjs.github.io/node-mssql/#connection-pools
25+
export const mssqlPool = {
26+
get: (name, config) => {
27+
if (!pools.has(name)) {
28+
if (!config) {
29+
throw new Error("Database configuration required");
30+
}
31+
32+
const pool = new mssql.ConnectionPool(config);
33+
const close = pool.close.bind(pool);
34+
pool.close = (...args) => {
35+
pools.delete(name);
36+
return close(...args);
37+
};
38+
39+
pools.set(name, pool.connect());
40+
}
41+
42+
return pools.get(name);
43+
},
44+
45+
closeAll: () =>
46+
Promise.all(
47+
Array.from(pools.values()).map((connect) => {
48+
return connect.then((pool) => pool.close());
49+
})
50+
),
51+
};
52+
53+
export async function queryStream(req, res, pool) {
54+
const db = await pool;
55+
const body = await json(req);
56+
57+
if (!validate(body)) throw badRequest();
58+
59+
res.setHeader("Content-Type", "text/plain");
60+
const keepAlive = setInterval(() => res.write("\n"), 25e3);
61+
62+
let {sql, params = []} = body;
63+
64+
try {
65+
await new Promise((resolve, reject) => {
66+
const request = new mssql.Request(db);
67+
const stream = request.toReadableStream();
68+
69+
params.forEach((param, idx) => {
70+
request.input(`${idx + 1}`, param);
71+
});
72+
73+
request.query(sql);
74+
request.once("recordset", () => clearInterval(keepAlive));
75+
request.on("recordset", (columns) => {
76+
const schema = {
77+
type: "array",
78+
items: {
79+
type: "object",
80+
properties: Object.entries(columns).reduce(
81+
(schema, [name, props]) => {
82+
return {
83+
...schema,
84+
...{[name]: dataTypeSchema({type: props.type.name})},
85+
};
86+
},
87+
{}
88+
),
89+
},
90+
};
91+
92+
res.write(`${JSON.stringify(schema)}`);
93+
res.write("\n");
94+
});
95+
96+
stream.pipe(JSONStream.stringify("", "\n", "\n")).pipe(res);
97+
stream.on("done", () => {
98+
resolve();
99+
});
100+
stream.on("error", (error) => {
101+
if (!request.canceled) {
102+
request.cancel();
103+
}
104+
reject(error);
105+
});
106+
});
107+
} catch (error) {
108+
if (!error.statusCode) error.statusCode = 400;
109+
throw error;
110+
} finally {
111+
clearInterval(keepAlive);
112+
}
113+
114+
res.end();
115+
}
116+
117+
/*
118+
* This function is checking for the permission of the given credentials. It alerts the user setting
119+
* them up that these may be too permissive.
120+
* */
121+
export async function check(req, res, pool) {
122+
const db = await pool;
123+
124+
// See: https://learn.microsoft.com/en-us/sql/relational-databases/system-functions/sys-fn-my-permissions-transact-sql
125+
const rows = await db.request().query(
126+
`USE ${db.config.database};
127+
SELECT * FROM fn_my_permissions (NULL, 'DATABASE');`
128+
);
129+
130+
const grants = rows.recordset.map((rs) => rs.permission_name);
131+
const permissive = grants.filter((g) => !READ_ONLY.has(g));
132+
133+
if (permissive.length)
134+
throw failedCheck(
135+
`User has too permissive grants: ${permissive.join(", ")}`
136+
);
137+
138+
return {ok: true};
139+
}
140+
141+
export default (credentials) => async (req, res) => {
142+
const pool = mssqlPool.get(JSON.stringify(credentials), credentials);
143+
144+
if (req.method === "POST") {
145+
if (req.url === "/check") {
146+
return check(req, res, pool);
147+
}
148+
149+
if (["/query-stream"].includes(req.url)) {
150+
return queryStream(req, res, pool);
151+
}
152+
153+
throw notImplemented();
154+
}
155+
};
156+
157+
// See https://github.com/tediousjs/node-mssql/blob/66587d97c9ce21bffba8ca360c72a540f2bc47a6/lib/datatypes.js#L6
158+
const boolean = ["null", "boolean"],
159+
integer = ["null", "integer"],
160+
number = ["null", "number"],
161+
object = ["null", "object"],
162+
string = ["null", "string"];
163+
export function dataTypeSchema({type}) {
164+
switch (type) {
165+
case TYPES.Bit.name:
166+
return {type: boolean};
167+
case TYPES.TinyInt.name:
168+
return {type: integer, tiny: true};
169+
case TYPES.SmallInt.name:
170+
return {type: integer, short: true};
171+
case TYPES.BigInt.name:
172+
return {type: integer, long: true};
173+
case TYPES.Int.name:
174+
return {type: integer};
175+
case TYPES.Float.name:
176+
return {type: number, float: true};
177+
case TYPES.Numeric.name:
178+
return {type: number};
179+
case TYPES.Decimal.name:
180+
return {type: number, decimal: true};
181+
case TYPES.Real.name:
182+
return {type: number};
183+
case TYPES.Date.name:
184+
case TYPES.DateTime.name:
185+
case TYPES.DateTime2.name:
186+
case TYPES.DateTimeOffset.name:
187+
case TYPES.SmallDateTime.name:
188+
case TYPES.Time.name:
189+
return {type: string, date: true};
190+
case TYPES.Binary.name:
191+
case TYPES.VarBinary.name:
192+
case TYPES.Image.name:
193+
return {type: object, buffer: true};
194+
case TYPES.SmallMoney.name: // TODO
195+
case TYPES.Money.name: //TODO
196+
case TYPES.Xml.name: //TODO
197+
case TYPES.TVP.name: //TODO
198+
case TYPES.UDT.name: //TODO
199+
case TYPES.Geography.name: //TODO
200+
case TYPES.Geometry.name: //TODO
201+
case TYPES.Variant.name: //TODO
202+
default:
203+
return {type: string};
204+
}
205+
}

lib/server.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {notFound, unauthorized, exit} from "./errors.js";
1111
import mysql from "./mysql.js";
1212
import postgres from "./postgres.js";
1313
import snowflake from "./snowflake.js";
14+
import mssql from "./mssql.js";
1415

1516
export function server(config, argv) {
1617
const development = process.env.NODE_ENV === "development";
@@ -22,7 +23,7 @@ export function server(config, argv) {
2223
url,
2324
ssl = "disabled",
2425
host = "127.0.0.1",
25-
port = 2899
26+
port = 2899,
2627
} = config;
2728

2829
const handler =
@@ -32,6 +33,8 @@ export function server(config, argv) {
3233
? postgres(url)
3334
: type === "snowflake"
3435
? snowflake(url)
36+
: type === "mssql"
37+
? mssql(url)
3538
: null;
3639
if (!handler) {
3740
return exit(`Unknown database type: ${type}`);
@@ -85,7 +88,7 @@ export function server(config, argv) {
8588

8689
const [payload, hmac] = authorization
8790
.split(".")
88-
.map(encoded => Buffer.from(encoded, "base64"));
91+
.map((encoded) => Buffer.from(encoded, "base64"));
8992
const {name} = JSON.parse(payload);
9093
if (config.name !== name) throw notFound();
9194
const {origin, secret} = config;

0 commit comments

Comments
 (0)