query splitter extracted into separate repository

This commit is contained in:
Jan Prochazka
2022-04-07 08:17:38 +02:00
parent 30e52723dd
commit 85e449953f
24 changed files with 14 additions and 943 deletions

View File

@@ -25,7 +25,7 @@
"compare-versions": "^3.6.0",
"cors": "^2.8.5",
"cross-env": "^6.0.3",
"dbgate-query-splitter": "^4.1.1",
"dbgate-query-splitter": "^4.8.3",
"dbgate-sqltree": "^4.1.1",
"dbgate-tools": "^4.1.1",
"diff": "^5.0.0",

View File

@@ -1 +0,0 @@
lib

View File

@@ -1,53 +0,0 @@
[![NPM version](https://img.shields.io/npm/v/dbgate-query-splitter.svg)](https://www.npmjs.com/package/dbgate-query-splitter)
# dbgate-query-splitter
Splits long SQL query into into particular statements. Designed to have zero dependencies and to be fast. Also supports nodejs-streams.
Supports following SQL dialects:
- MySQL
- PostgreSQL
- SQLite
- Microsoft SQL Server
## Usage
```js
import { splitQuery, mysqlSplitterOptions, mssqlSplitterOptions, postgreSplitterOptions } from 'dbgate-query-splitter';
const output = splitQuery('SELECT * FROM `table1`;SELECT * FROM `table2`;', mysqlSplitterOptions);
// output is ['SELECT * FROM `table1`', 'SELECT * FROM `table2`']
```
## Streaming support in nodejs
Function splitQueryStream accepts input stream and query options. Result is object stream, each object for one splitted query.
Tokens must not be divided into more input chunks. This can be accomplished eg. when input stream emits one chunk per line (eg. using byline module)
```js
const { mysqlSplitterOptions, mssqlSplitterOptions, postgreSplitterOptions } = require('dbgate-query-splitter');
const { splitQueryStream } = require('dbgate-query-splitter/lib/splitQueryStream');
const fs = require('fs');
const byline = require('byline');
const fileStream = fs.createReadStream('INPUT_FILE_NAME', 'utf-8');
const lineStream = byline(fileStream);
const splittedStream = splitQueryStream(lineStream, mysqlSplitterOptions);
```
## Contributing
Please run tests before pushing any changes.
```sh
yarn test
```
## Supported syntax
- Comments
- Dollar strings (PostgreSQL)
- GO separators (MS SQL)
- Custom delimiter, setby DELIMITER keyword (MySQL)

View File

@@ -1,5 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['js'],
};

View File

@@ -1,37 +0,0 @@
{
"version": "4.1.1",
"name": "dbgate-query-splitter",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"description": "SQL Query splitter for verious database engines",
"homepage": "https://github.com/dbgate/dbgate/tree/master/packages/query-splitter",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate"
},
"author": "Jan Prochazka",
"license": "MIT",
"keywords": [
"SQL",
"query",
"split",
"parse"
],
"scripts": {
"build": "tsc",
"start": "tsc --watch",
"test": "jest",
"test:ci": "jest --json --outputFile=result.json --testLocationInResults"
},
"files": [
"lib"
],
"devDependencies": {
"dbgate-types": "^4.1.1",
"@types/jest": "^25.1.4",
"@types/node": "^13.7.0",
"jest": "^24.9.0",
"ts-jest": "^25.2.1",
"typescript": "^4.4.3"
}
}

View File

@@ -1,2 +0,0 @@
export { splitQuery } from './splitQuery';
export * from './options';

View File

@@ -1,93 +0,0 @@
export interface SplitterOptions {
stringsBegins: string[];
stringsEnds: { [begin: string]: string };
stringEscapes: { [begin: string]: string };
allowSemicolon: boolean;
allowCustomDelimiter: boolean;
allowGoDelimiter: boolean;
allowDollarDollarString: boolean;
noSplit: boolean;
doubleDashComments: boolean;
multilineComments: boolean;
javaScriptComments: boolean;
returnRichInfo: boolean;
splitByLines: boolean;
}
export const defaultSplitterOptions: SplitterOptions = {
stringsBegins: ["'"],
stringsEnds: { "'": "'" },
stringEscapes: { "'": "'" },
allowSemicolon: true,
allowCustomDelimiter: false,
allowGoDelimiter: false,
allowDollarDollarString: false,
noSplit: false,
doubleDashComments: true,
multilineComments: true,
javaScriptComments: false,
returnRichInfo: false,
splitByLines: false,
};
export const mysqlSplitterOptions: SplitterOptions = {
...defaultSplitterOptions,
allowCustomDelimiter: true,
stringsBegins: ["'", '`'],
stringsEnds: { "'": "'", '`': '`' },
stringEscapes: { "'": '\\', '`': '`' },
};
export const mssqlSplitterOptions: SplitterOptions = {
...defaultSplitterOptions,
allowSemicolon: false,
allowGoDelimiter: true,
stringsBegins: ["'", '['],
stringsEnds: { "'": "'", '[': ']' },
stringEscapes: { "'": "'" },
};
export const postgreSplitterOptions: SplitterOptions = {
...defaultSplitterOptions,
allowDollarDollarString: true,
stringsBegins: ["'", '"'],
stringsEnds: { "'": "'", '"': '"' },
stringEscapes: { "'": "'", '"': '"' },
};
export const sqliteSplitterOptions: SplitterOptions = {
...defaultSplitterOptions,
stringsBegins: ["'", '"'],
stringsEnds: { "'": "'", '"': '"' },
stringEscapes: { "'": "'", '"': '"' },
};
export const mongoSplitterOptions: SplitterOptions = {
...defaultSplitterOptions,
stringsBegins: ["'", '"'],
stringsEnds: { "'": "'", '"': '"' },
stringEscapes: { "'": '\\', '"': '\\' },
};
export const noSplitSplitterOptions: SplitterOptions = {
...defaultSplitterOptions,
noSplit: true,
};
export const redisSplitterOptions: SplitterOptions = {
...defaultSplitterOptions,
splitByLines: true,
};

View File

@@ -1,431 +0,0 @@
import { SplitterOptions, defaultSplitterOptions } from './options';
const SEMICOLON = ';';
export interface SplitStreamContext {
options: SplitterOptions;
currentDelimiter: string;
pushOutput: (item: SplitResultItem) => void;
commandPart: string;
line: number;
column: number;
streamPosition: number;
commandStartPosition: number;
commandStartLine: number;
commandStartColumn: number;
}
export interface SplitLineContext extends SplitStreamContext {
source: string;
position: number;
// output: string[];
end: number;
wasDataOnLine: boolean;
currentCommandStart: number;
// unread: string;
// currentStatement: string;
// semicolonKeyTokenRegex: RegExp;
}
export interface SplitPositionDefinition {
position: number;
line: number;
column: number;
}
export interface SplitResultItemRich {
text: string;
start: SplitPositionDefinition;
end: SplitPositionDefinition;
trimStart?: SplitPositionDefinition;
trimEnd?: SplitPositionDefinition;
}
export type SplitResultItem = string | SplitResultItemRich;
function movePosition(context: SplitLineContext, count: number) {
if (context.options.returnRichInfo) {
let { source, position, line, column, streamPosition } = context;
while (count > 0) {
if (source[position] == '\n') {
line += 1;
column = 0;
} else {
column += 1;
}
position += 1;
streamPosition += 1;
count -= 1;
}
context.position = position;
context.streamPosition = streamPosition;
context.line = line;
context.column = column;
} else {
context.position += count;
}
}
function isStringEnd(s: string, pos: number, endch: string, escapech: string) {
if (!escapech) {
return s[pos] == endch;
}
if (endch == escapech) {
return s[pos] == endch && s[pos + 1] != endch;
} else {
return s[pos] == endch && s[pos - 1] != escapech;
}
}
interface Token {
type: 'string' | 'delimiter' | 'whitespace' | 'eoln' | 'data' | 'set_delimiter' | 'comment' | 'go_delimiter';
length: number;
value?: string;
}
const WHITESPACE_TOKEN: Token = {
type: 'whitespace',
length: 1,
};
const EOLN_TOKEN: Token = {
type: 'eoln',
length: 1,
};
const DATA_TOKEN: Token = {
type: 'data',
length: 1,
};
function scanDollarQuotedString(context: SplitLineContext): Token {
if (!context.options.allowDollarDollarString) return null;
let pos = context.position;
const s = context.source;
const match = /^(\$[a-zA-Z0-9_]*\$)/.exec(s.slice(pos));
if (!match) return null;
const label = match[1];
pos += label.length;
while (pos < context.end) {
if (s.slice(pos).startsWith(label)) {
return {
type: 'string',
length: pos + label.length - context.position,
};
}
pos++;
}
return null;
}
function scanToken(context: SplitLineContext): Token {
let pos = context.position;
const s = context.source;
const ch = s[pos];
if (context.options.stringsBegins.includes(ch)) {
pos++;
const endch = context.options.stringsEnds[ch];
const escapech = context.options.stringEscapes[ch];
while (pos < context.end && !isStringEnd(s, pos, endch, escapech)) {
if (endch == escapech && s[pos] == endch && s[pos + 1] == endch) {
pos += 2;
} else {
pos++;
}
}
return {
type: 'string',
length: pos - context.position + 1,
};
}
if (context.currentDelimiter && s.slice(pos).startsWith(context.currentDelimiter)) {
return {
type: 'delimiter',
length: context.currentDelimiter.length,
};
}
if (ch == ' ' || ch == '\t' || ch == '\r') {
return WHITESPACE_TOKEN;
}
if (ch == '\n') {
return EOLN_TOKEN;
}
if (context.options.doubleDashComments && ch == '-' && s[pos + 1] == '-') {
while (pos < context.end && s[pos] != '\n') pos++;
return {
type: 'comment',
length: pos - context.position,
};
}
if (context.options.multilineComments && ch == '/' && s[pos + 1] == '*') {
pos += 2;
while (pos < context.end) {
if (s[pos] == '*' && s[pos + 1] == '/') break;
pos++;
}
return {
type: 'comment',
length: pos - context.position + 2,
};
}
if (context.options.allowCustomDelimiter && !context.wasDataOnLine) {
const m = s.slice(pos).match(/^DELIMITER[ \t]+([^\n]+)/i);
if (m) {
return {
type: 'set_delimiter',
value: m[1].trim(),
length: m[0].length,
};
}
}
if (context.options.allowGoDelimiter && !context.wasDataOnLine) {
const m = s.slice(pos).match(/^GO[\t\r ]*(\n|$)/i);
if (m) {
return {
type: 'go_delimiter',
length: m[0].length - 1,
};
}
}
const dollarString = scanDollarQuotedString(context);
if (dollarString) return dollarString;
return DATA_TOKEN;
}
function pushQuery(context: SplitLineContext) {
const sql = (context.commandPart || '') + context.source.slice(context.currentCommandStart, context.position);
const trimmed = sql.trim();
if (trimmed) {
if (context.options.returnRichInfo) {
context.pushOutput(
countTrimmedPositions(sql, {
text: trimmed,
start: {
position: context.commandStartPosition,
line: context.commandStartLine,
column: context.commandStartColumn,
},
end: {
position: context.streamPosition,
line: context.line,
column: context.column,
},
})
);
} else {
context.pushOutput(trimmed);
}
}
}
function countTrimmedPositions(full: string, positions: SplitResultItemRich): SplitResultItemRich {
const startIndex = full.indexOf(positions.text);
const trimStart = { ...positions.start };
for (let i = 0; i < startIndex; i += 1) {
if (full[i] == '\n') {
trimStart.position += 1;
trimStart.line += 1;
trimStart.column = 0;
} else {
trimStart.position += 1;
trimStart.column += 1;
}
}
return {
...positions,
trimStart,
trimEnd: positions.end,
};
}
function markStartCommand(context: SplitLineContext) {
if (context.options.returnRichInfo) {
context.commandStartPosition = context.streamPosition;
context.commandStartLine = context.line;
context.commandStartColumn = context.column;
}
}
function splitByLines(context: SplitLineContext) {
while (context.position < context.end) {
if (context.source[context.position] == '\n') {
pushQuery(context);
context.commandPart = '';
movePosition(context, 1);
context.currentCommandStart = context.position;
markStartCommand(context);
} else {
movePosition(context, 1);
}
}
if (context.end > context.currentCommandStart) {
context.commandPart += context.source.slice(context.currentCommandStart, context.position);
}
}
export function splitQueryLine(context: SplitLineContext) {
if (context.options.splitByLines) {
splitByLines(context);
return;
}
while (context.position < context.end) {
const token = scanToken(context);
if (!token) {
// nothing special, move forward
movePosition(context, 1);
continue;
}
switch (token.type) {
case 'string':
movePosition(context, token.length);
context.wasDataOnLine = true;
break;
case 'comment':
movePosition(context, token.length);
context.wasDataOnLine = true;
break;
case 'eoln':
movePosition(context, token.length);
context.wasDataOnLine = false;
break;
case 'data':
movePosition(context, token.length);
context.wasDataOnLine = true;
break;
case 'whitespace':
movePosition(context, token.length);
break;
case 'set_delimiter':
pushQuery(context);
context.commandPart = '';
context.currentDelimiter = token.value;
movePosition(context, token.length);
context.currentCommandStart = context.position;
markStartCommand(context);
break;
case 'go_delimiter':
pushQuery(context);
context.commandPart = '';
movePosition(context, token.length);
context.currentCommandStart = context.position;
markStartCommand(context);
break;
case 'delimiter':
pushQuery(context);
context.commandPart = '';
movePosition(context, token.length);
context.currentCommandStart = context.position;
markStartCommand(context);
break;
}
}
if (context.end > context.currentCommandStart) {
context.commandPart += context.source.slice(context.currentCommandStart, context.position);
}
}
export function getInitialDelimiter(options: SplitterOptions) {
return options?.allowSemicolon === false ? null : SEMICOLON;
}
export function finishSplitStream(context: SplitStreamContext) {
const trimmed = context.commandPart.trim();
if (trimmed) {
if (context.options.returnRichInfo) {
context.pushOutput(
countTrimmedPositions(context.commandPart, {
text: trimmed,
start: {
position: context.commandStartPosition,
line: context.commandStartLine,
column: context.commandStartColumn,
},
end: {
position: context.streamPosition,
line: context.line,
column: context.column,
},
})
);
} else {
context.pushOutput(trimmed);
}
}
}
export function splitQuery(sql: string, options: SplitterOptions = null): SplitResultItem[] {
const usedOptions = {
...defaultSplitterOptions,
...options,
};
if (usedOptions.noSplit) {
if (usedOptions.returnRichInfo) {
const lines = sql.split('\n');
return [
{
text: sql,
start: {
position: 0,
line: 0,
column: 0,
},
end: {
position: sql.length,
line: lines.length,
column: lines[lines.length - 1]?.length || 0,
},
},
];
}
return [sql];
}
const output = [];
const context: SplitLineContext = {
source: sql,
end: sql.length,
currentDelimiter: getInitialDelimiter(options),
position: 0,
column: 0,
line: 0,
currentCommandStart: 0,
commandStartLine: 0,
commandStartColumn: 0,
commandStartPosition: 0,
streamPosition: 0,
pushOutput: cmd => output.push(cmd),
wasDataOnLine: false,
options: usedOptions,
commandPart: '',
};
splitQueryLine(context);
finishSplitStream(context);
return output;
}

View File

@@ -1,52 +0,0 @@
import stream from 'stream';
import {
SplitStreamContext,
getInitialDelimiter,
SplitLineContext,
splitQueryLine,
finishSplitStream,
} from './splitQuery';
import { SplitterOptions } from './options';
export class SplitQueryStream extends stream.Transform {
context: SplitStreamContext;
constructor(options: SplitterOptions) {
super({ objectMode: true });
this.context = {
commandPart: '',
commandStartLine: 0,
commandStartColumn: 0,
commandStartPosition: 0,
streamPosition: 0,
line: 0,
column: 0,
options,
currentDelimiter: getInitialDelimiter(options),
pushOutput: cmd => this.push(cmd),
};
}
_transform(chunk, encoding, done) {
const lineContext: SplitLineContext = {
...this.context,
position: 0,
currentCommandStart: 0,
wasDataOnLine: false,
source: chunk,
end: chunk.length,
};
splitQueryLine(lineContext);
this.context.commandPart = lineContext.commandPart;
done();
}
_flush(done) {
finishSplitStream(this.context);
done();
}
}
export function splitQueryStream(sourceStream, options: SplitterOptions) {
const splitter = new SplitQueryStream(options);
sourceStream.pipe(splitter);
return splitter;
}

View File

@@ -1,184 +0,0 @@
import {
mysqlSplitterOptions,
mssqlSplitterOptions,
postgreSplitterOptions,
mongoSplitterOptions,
noSplitSplitterOptions,
redisSplitterOptions,
} from './options';
import { splitQuery } from './splitQuery';
test('simple query', () => {
const output = splitQuery('select * from A');
expect(output).toEqual(['select * from A']);
});
test('correct split 2 queries', () => {
const output = splitQuery('SELECT * FROM `table1`;SELECT * FROM `table2`;', mysqlSplitterOptions);
expect(output).toEqual(['SELECT * FROM `table1`', 'SELECT * FROM `table2`']);
});
test('correct split 2 queries - no end semicolon', () => {
const output = splitQuery('SELECT * FROM `table1`;SELECT * FROM `table2`', mysqlSplitterOptions);
expect(output).toEqual(['SELECT * FROM `table1`', 'SELECT * FROM `table2`']);
});
test('delete empty query', () => {
const output = splitQuery(';;;\n;;SELECT * FROM `table1`;;;;;SELECT * FROM `table2`;;; ;;;', mysqlSplitterOptions);
expect(output).toEqual(['SELECT * FROM `table1`', 'SELECT * FROM `table2`']);
});
test('should handle double backtick', () => {
const input = ['CREATE TABLE `a``b` (`c"d` INT)', 'CREATE TABLE `a````b` (`c"d` INT)'];
const output = splitQuery(input.join(';\n') + ';', mysqlSplitterOptions);
expect(output).toEqual(input);
});
test('semicolon inside string', () => {
const input = ['CREATE TABLE a', "INSERT INTO a (x) VALUES ('1;2;3;4')"];
const output = splitQuery(input.join(';\n') + ';', mysqlSplitterOptions);
expect(output).toEqual(input);
});
test('semicolon inside identyifier - mssql', () => {
const input = ['CREATE TABLE [a;1]', "INSERT INTO [a;1] (x) VALUES ('1')"];
const output = splitQuery(input.join(';\n') + ';', {
...mssqlSplitterOptions,
allowSemicolon: true,
});
expect(output).toEqual(input);
});
test('delimiter test', () => {
const input = 'SELECT 1;\n DELIMITER $$\n SELECT 2; SELECT 3; \n DELIMITER ;';
const output = splitQuery(input, mysqlSplitterOptions);
expect(output).toEqual(['SELECT 1', 'SELECT 2; SELECT 3;']);
});
test('one line comment test', () => {
const input = 'SELECT 1 -- comment1;comment2\n;SELECT 2';
const output = splitQuery(input, mysqlSplitterOptions);
expect(output).toEqual(['SELECT 1 -- comment1;comment2', 'SELECT 2']);
});
test('multi line comment test', () => {
const input = 'SELECT 1 /* comment1;comment2\ncomment3*/;SELECT 2';
const output = splitQuery(input, mysqlSplitterOptions);
expect(output).toEqual(['SELECT 1 /* comment1;comment2\ncomment3*/', 'SELECT 2']);
});
test('dollar string', () => {
const input = 'CREATE PROC $$ SELECT 1; SELECT 2; $$ ; SELECT 3';
const output = splitQuery(input, postgreSplitterOptions);
expect(output).toEqual(['CREATE PROC $$ SELECT 1; SELECT 2; $$', 'SELECT 3']);
});
test('go delimiter', () => {
const input = 'SELECT 1\ngo\nSELECT 2';
const output = splitQuery(input, mssqlSplitterOptions);
expect(output).toEqual(['SELECT 1', 'SELECT 2']);
});
test('no split', () => {
const input = 'SELECT 1;SELECT 2';
const output = splitQuery(input, noSplitSplitterOptions);
expect(output).toEqual(['SELECT 1;SELECT 2']);
});
test('split mongo', () => {
const input = 'db.collection.insert({x:1});db.collection.insert({y:2})';
const output = splitQuery(input, mongoSplitterOptions);
expect(output).toEqual(['db.collection.insert({x:1})', 'db.collection.insert({y:2})']);
});
test('redis split by newline', () => {
const output = splitQuery('SET x 1\nSET y 2', redisSplitterOptions);
expect(output).toEqual(['SET x 1', 'SET y 2']);
});
test('redis split by newline 2', () => {
const output = splitQuery('SET x 1\n\nSET y 2\n', redisSplitterOptions);
expect(output).toEqual(['SET x 1', 'SET y 2']);
});
test('count lines', () => {
const output = splitQuery('SELECT * FROM `table1`;\nSELECT * FROM `table2`;', {
...mysqlSplitterOptions,
returnRichInfo: true,
});
expect(output).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'SELECT * FROM `table1`',
trimStart: expect.objectContaining({
position: 0,
line: 0,
column: 0,
}),
end: expect.objectContaining({
position: 22,
line: 0,
column: 22,
}),
}),
expect.objectContaining({
text: 'SELECT * FROM `table2`',
trimStart: expect.objectContaining({
position: 24,
line: 1,
column: 0,
}),
end: expect.objectContaining({
position: 46,
line: 1,
column: 22,
}),
}),
])
);
});
test('count lines with flush', () => {
const output = splitQuery('SELECT * FROM `table1`;\nSELECT * FROM `table2`', {
...mysqlSplitterOptions,
returnRichInfo: true,
});
expect(output).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'SELECT * FROM `table1`',
trimStart: expect.objectContaining({
position: 0,
line: 0,
column: 0,
}),
end: expect.objectContaining({
position: 22,
line: 0,
column: 22,
}),
}),
expect.objectContaining({
text: 'SELECT * FROM `table2`',
trimStart: expect.objectContaining({
position: 24,
line: 1,
column: 0,
}),
end: expect.objectContaining({
position: 46,
line: 1,
column: 22,
}),
}),
])
);
});

View File

@@ -1,40 +0,0 @@
import { mysqlSplitterOptions, mssqlSplitterOptions, postgreSplitterOptions, noSplitSplitterOptions } from './options';
import stream from 'stream';
import { splitQueryStream } from './splitQueryStream';
function createInputStream(...lines) {
const pass = new stream.PassThrough({
objectMode: true,
});
lines.forEach(line => pass.write(line));
pass.end();
return pass;
}
function streamToArray(streamSource) {
return new Promise((resolve, reject) => {
const res = [];
streamSource.on('data', x => res.push(x));
streamSource.on('end', () => resolve(res));
});
}
test('stream: simple query', async () => {
const output = await streamToArray(splitQueryStream(createInputStream('select * from A'), mysqlSplitterOptions));
expect(output).toEqual(['select * from A']);
});
test('stream: query on 2 lines', async () => {
const output = await streamToArray(splitQueryStream(createInputStream('select * ', 'from A'), mysqlSplitterOptions));
expect(output).toEqual(['select * from A']);
});
test('stream: query on 2 lines', async () => {
const output = await streamToArray(
splitQueryStream(
createInputStream('SELECT * ', 'FROM `table1`;', 'SELECT *', ' FROM `table2`'),
mysqlSplitterOptions
)
);
expect(output).toEqual(['SELECT * FROM `table1`', 'SELECT * FROM `table2`']);
});

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs",
"declaration": true,
"skipLibCheck": true,
"outDir": "lib",
"preserveWatchOutput": true,
"esModuleInterop": true
},
"include": [
"src/**/*"
]
}

View File

@@ -32,7 +32,7 @@
},
"dependencies": {
"lodash": "^4.17.21",
"dbgate-query-splitter": "^4.1.1",
"dbgate-query-splitter": "^4.8.3",
"dbgate-sqltree": "^4.1.1",
"uuid": "^3.4.0"
}

View File

@@ -24,7 +24,7 @@
"chartjs-adapter-moment": "^1.0.0",
"cross-env": "^7.0.3",
"dbgate-datalib": "^4.1.1",
"dbgate-query-splitter": "^4.1.1",
"dbgate-query-splitter": "^4.8.3",
"dbgate-sqltree": "^4.1.1",
"dbgate-tools": "^4.1.1",
"dbgate-types": "^4.1.1",