From 0380951e24624bedaf7c899af1c6e3223a47bb65 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 10:54:01 -0800 Subject: [PATCH 01/11] utils: Initial setup --- packages/utils/.eslintignore | 4 ++ packages/utils/.eslintrc.cjs | 3 + packages/utils/.npmignore | 2 + packages/utils/babel.config.js | 6 ++ packages/utils/jest.config.js | 12 ++++ packages/utils/package.json | 58 +++++++++++++++++++ packages/utils/rollup.config.js | 39 +++++++++++++ .../utils/src/__tests__/safeJSONParse.test.ts | 28 +++++++++ packages/utils/src/index.ts | 1 + packages/utils/src/safeJSONParse.ts | 9 +++ packages/utils/tsconfig.build.json | 4 ++ packages/utils/tsconfig.json | 24 ++++++++ tsconfig.json | 2 +- yarn.lock | 28 +++++++++ 14 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 packages/utils/.eslintignore create mode 100644 packages/utils/.eslintrc.cjs create mode 100644 packages/utils/.npmignore create mode 100644 packages/utils/babel.config.js create mode 100644 packages/utils/jest.config.js create mode 100644 packages/utils/package.json create mode 100644 packages/utils/rollup.config.js create mode 100644 packages/utils/src/__tests__/safeJSONParse.test.ts create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/safeJSONParse.ts create mode 100644 packages/utils/tsconfig.build.json create mode 100644 packages/utils/tsconfig.json diff --git a/packages/utils/.eslintignore b/packages/utils/.eslintignore new file mode 100644 index 00000000..fad00ec9 --- /dev/null +++ b/packages/utils/.eslintignore @@ -0,0 +1,4 @@ +dist +node_modules +.eslintrc.js +./*.js diff --git a/packages/utils/.eslintrc.cjs b/packages/utils/.eslintrc.cjs new file mode 100644 index 00000000..041fb773 --- /dev/null +++ b/packages/utils/.eslintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: "../../.eslintrc.cjs", +}; diff --git a/packages/utils/.npmignore b/packages/utils/.npmignore new file mode 100644 index 00000000..54fcfddb --- /dev/null +++ b/packages/utils/.npmignore @@ -0,0 +1,2 @@ +src/ +tsconfig.* \ No newline at end of file diff --git a/packages/utils/babel.config.js b/packages/utils/babel.config.js new file mode 100644 index 00000000..dd242dc9 --- /dev/null +++ b/packages/utils/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ["@babel/preset-env", { targets: { node: "current" } }], + "@babel/preset-typescript", + ], +}; diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js new file mode 100644 index 00000000..e61a4089 --- /dev/null +++ b/packages/utils/jest.config.js @@ -0,0 +1,12 @@ +const TEST_REGEX = "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|js?|tsx?|ts?)$"; + +module.exports = { + testRegex: TEST_REGEX, + transform: { + "^.+\\.tsx?$": "babel-jest", + }, + testPathIgnorePatterns: ["types", "node_modules", ".rollup.cache", "dist"], + moduleFileExtensions: ["ts", "js"], + collectCoverage: false, + clearMocks: true, +}; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 00000000..0a98c339 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,58 @@ +{ + "name": "@dolthub/web-utils", + "author": "DoltHub", + "version": "0.1.6", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "keywords": [ + "web", + "utils", + "frontend" + ], + "packageManager": "yarn@4.0.2", + "scripts": { + "compile": "tsc -b", + "build": "rollup -c --bundleConfigAsCjs", + "dbuild": "yarn compile && yarn build", + "lint": "eslint --cache --ext .ts,.js,.tsx,.jsx src", + "prettier": "prettier --check 'src/**/*.{js,ts}'", + "prettier-fix": "prettier --write 'src/**/*.{js,ts}'", + "test": "jest", + "yalc:publish": "yarn dbuild && yalc publish", + "yalc:push": "yarn dbuild && yalc push" + }, + "devDependencies": { + "@babel/core": "^7.23.7", + "@babel/preset-env": "^7.23.8", + "@babel/preset-typescript": "^7.23.3", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.5", + "@types/eslint": "^8", + "@types/jest": "^29.5.11", + "@types/rollup-plugin-peer-deps-external": "^2", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", + "babel-jest": "^29.7.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "rollup": "^4.9.4", + "rollup-plugin-dts": "^6.1.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-terser": "^7.0.2", + "typescript": "^5.3.3", + "yalc": "^1.0.0-pre.53" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dolthub/react-library.git" + }, + "bugs": { + "url": "https://github.com/dolthub/react-library/issues" + } +} diff --git a/packages/utils/rollup.config.js b/packages/utils/rollup.config.js new file mode 100644 index 00000000..f58bddd6 --- /dev/null +++ b/packages/utils/rollup.config.js @@ -0,0 +1,39 @@ +import resolve from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import typescript from "@rollup/plugin-typescript"; +import { terser } from "rollup-plugin-terser"; +import external from "rollup-plugin-peer-deps-external"; +import { dts } from "rollup-plugin-dts"; + +const packageJson = require("./package.json"); + +export default [ + { + input: "src/index.ts", + output: [ + { + file: packageJson.main, + format: "cjs", + sourcemap: true, + name: "utils-ts-lib", + }, + { + file: packageJson.module, + format: "esm", + sourcemap: true, + }, + ], + plugins: [ + external(), + resolve(), + commonjs(), + typescript({ tsconfig: "./tsconfig.json" }), + terser(), + ], + }, + { + input: "./types/index.d.ts", + output: [{ file: "dist/index.d.ts", format: "esm" }], + plugins: [dts()], + }, +]; diff --git a/packages/utils/src/__tests__/safeJSONParse.test.ts b/packages/utils/src/__tests__/safeJSONParse.test.ts new file mode 100644 index 00000000..78403e8c --- /dev/null +++ b/packages/utils/src/__tests__/safeJSONParse.test.ts @@ -0,0 +1,28 @@ +import safeJSONParse from "../safeJSONParse"; + +describe("test safeJSONParse", () => { + it("works for valid json", () => { + const validJson = [ + '{"test": 1}', + "{}", + "[]", + "[1, 2, 3]", + '"valid string"', + ]; + validJson.forEach(json => { + expect(safeJSONParse(json)).toEqual(JSON.parse(json)); + }); + }); + + it("works for invalid json", () => { + const invalidJson = [ + "invalid string", + "{invalid}", + "[invalid]", + '{"good": bad}', + ]; + invalidJson.forEach(json => { + expect(safeJSONParse(json)).toEqual(json); + }); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 00000000..41f9e25a --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1 @@ +export { default as safeJSONParse } from "./safeJSONParse"; diff --git a/packages/utils/src/safeJSONParse.ts b/packages/utils/src/safeJSONParse.ts new file mode 100644 index 00000000..d78c9980 --- /dev/null +++ b/packages/utils/src/safeJSONParse.ts @@ -0,0 +1,9 @@ +// safeJSONParse attempts to parse a JSON string, and if it fails returns the +// original string instead of throwing an error +export default function safeJSONParse(text: string): any { + try { + return JSON.parse(text); + } catch (_) { + return text; + } +} diff --git a/packages/utils/tsconfig.build.json b/packages/utils/tsconfig.build.json new file mode 100644 index 00000000..36763f73 --- /dev/null +++ b/packages/utils/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["./lib", "./esm", "./cjs", "**/*.test.tsx", "**/*.test.ts", "**/*.story.tsx"] +} \ No newline at end of file diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 00000000..4654b109 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["./src"], + "exclude": ["./types", "./esm", "./cjs", "node_modules", "./dist"], + "compilerOptions": { + "rootDir": "src", + "baseUrl": ".", + "declaration": true, + "declarationDir": "types", + "outDir": "dist", + "target": "ES2015", + "lib": ["DOM", "ESNext"], + "module": "ESNext", + "moduleResolution": "Node", + "jsx": "react", + "emitDeclarationOnly": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "composite": true, + "noImplicitAny": true, + "types": ["node", "jsdom", "@testing-library/jest-dom"] + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 31a941a9..a21c7b9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": [ - "./packages/*/src", + "./packages/*/src", "packages/utils/src/__tests__/safeJSONParse.test.ts", ], "exclude": [ "node_modules", diff --git a/yarn.lock b/yarn.lock index 834b568d..0f8f5ab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1514,6 +1514,34 @@ __metadata: languageName: unknown linkType: soft +"@dolthub/web-utils@workspace:packages/utils": + version: 0.0.0-use.local + resolution: "@dolthub/web-utils@workspace:packages/utils" + dependencies: + "@babel/core": "npm:^7.23.7" + "@babel/preset-env": "npm:^7.23.8" + "@babel/preset-typescript": "npm:^7.23.3" + "@rollup/plugin-commonjs": "npm:^25.0.7" + "@rollup/plugin-node-resolve": "npm:^15.2.3" + "@rollup/plugin-typescript": "npm:^11.1.5" + "@types/eslint": "npm:^8" + "@types/jest": "npm:^29.5.11" + "@types/rollup-plugin-peer-deps-external": "npm:^2" + "@typescript-eslint/eslint-plugin": "npm:^6.18.1" + "@typescript-eslint/parser": "npm:^6.18.1" + babel-jest: "npm:^29.7.0" + eslint: "npm:^8.56.0" + jest: "npm:^29.7.0" + prettier: "npm:^3.1.0" + rollup: "npm:^4.9.4" + rollup-plugin-dts: "npm:^6.1.0" + rollup-plugin-peer-deps-external: "npm:^2.2.4" + rollup-plugin-terser: "npm:^7.0.2" + typescript: "npm:^5.3.3" + yalc: "npm:^1.0.0-pre.53" + languageName: unknown + linkType: soft + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" From 857b07ad748d472b6942f7832fe114bf84a2850b Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 10:57:29 -0800 Subject: [PATCH 02/11] utils, hooks: Use utils in hooks --- package.json | 10 +++++----- packages/hooks/package.json | 1 + packages/hooks/src/useSessionQueryHistory.ts | 11 +---------- packages/utils/package.json | 2 +- tsconfig.json | 2 +- yarn.lock | 3 ++- 6 files changed, 11 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 1e8be71a..0817786f 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "scripts": { "ci": "yarn prettier && yarn compile && yarn lint && yarn test && yarn build", "clean": "rimraf node_modules -g 'packages/*/.eslintcache' 'packages/*/*.tsbuildinfo' 'packages/*/dist' 'packages/*/.rollup.cache' 'packages/*/types' 'packages/*/coverage'", - "compile": "yarn workspace @dolthub/react-hooks compile", - "build": "yarn workspace @dolthub/react-hooks build", - "lint": "yarn workspace @dolthub/react-hooks lint", - "prettier": "yarn workspace @dolthub/react-hooks prettier", - "test": "yarn workspace @dolthub/react-hooks test" + "compile": "yarn workspace @dolthub/react-hooks compile && yarn workspace @dolthub/web-utils compile", + "build": "yarn workspace @dolthub/react-hooks build && yarn workspace @dolthub/web-utils build", + "lint": "yarn workspace @dolthub/react-hooks lint && yarn workspace @dolthub/web-utils lint", + "prettier": "yarn workspace @dolthub/react-hooks prettier && yarn workspace @dolthub/web-utils prettier", + "test": "yarn workspace @dolthub/react-hooks test && yarn workspace @dolthub/web-utils test" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.18.1", diff --git a/packages/hooks/package.json b/packages/hooks/package.json index f07ef234..f729da12 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -36,6 +36,7 @@ "@babel/preset-env": "^7.23.8", "@babel/preset-react": "^7.23.3", "@babel/preset-typescript": "^7.23.3", + "@dolthub/web-utils": "^0.1.0", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.5", diff --git a/packages/hooks/src/useSessionQueryHistory.ts b/packages/hooks/src/useSessionQueryHistory.ts index de8befff..d08eb768 100644 --- a/packages/hooks/src/useSessionQueryHistory.ts +++ b/packages/hooks/src/useSessionQueryHistory.ts @@ -1,3 +1,4 @@ +import { safeJSONParse } from "@dolthub/web-utils"; import { useState } from "react"; import useStateWithSessionStorage from "./useStateWithSessionStorage"; @@ -68,13 +69,3 @@ export default function useSessionQueryHistory( queryIdx, }; } - -// safeJSONParse attempts to parse a JSON string, and if it fails returns the -// original string instead of throwing an error -function safeJSONParse(text: string): any { - try { - return JSON.parse(text); - } catch (_) { - return text; - } -} diff --git a/packages/utils/package.json b/packages/utils/package.json index 0a98c339..31081616 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "name": "@dolthub/web-utils", "author": "DoltHub", - "version": "0.1.6", + "version": "0.1.0", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "types": "dist/index.d.ts", diff --git a/tsconfig.json b/tsconfig.json index a21c7b9c..31a941a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": [ - "./packages/*/src", "packages/utils/src/__tests__/safeJSONParse.test.ts", + "./packages/*/src", ], "exclude": [ "node_modules", diff --git a/yarn.lock b/yarn.lock index 0f8f5ab0..7e845b0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1475,6 +1475,7 @@ __metadata: "@babel/preset-env": "npm:^7.23.8" "@babel/preset-react": "npm:^7.23.3" "@babel/preset-typescript": "npm:^7.23.3" + "@dolthub/web-utils": "npm:^0.1.0" "@rollup/plugin-commonjs": "npm:^25.0.7" "@rollup/plugin-node-resolve": "npm:^15.2.3" "@rollup/plugin-typescript": "npm:^11.1.5" @@ -1514,7 +1515,7 @@ __metadata: languageName: unknown linkType: soft -"@dolthub/web-utils@workspace:packages/utils": +"@dolthub/web-utils@npm:^0.1.0, @dolthub/web-utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@dolthub/web-utils@workspace:packages/utils" dependencies: From c8215576e6daadbe83df5519777d6a2e57bdf4a6 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 11:06:17 -0800 Subject: [PATCH 03/11] utils: More utils --- packages/utils/src/Maybe.ts | 2 ++ packages/utils/src/__tests__/dedupe.test.ts | 32 +++++++++++++++++++ packages/utils/src/__tests__/enumKeys.test.ts | 18 +++++++++++ packages/utils/src/__tests__/excerpt.test.ts | 11 +++++++ .../src/__tests__/initialUppercase.test.ts | 7 ++++ packages/utils/src/__tests__/nTimes.test.ts | 20 ++++++++++++ .../utils/src/__tests__/prettyJSON.test.ts | 23 +++++++++++++ .../src/__tests__/randomArrayItem.test.ts | 11 +++++++ packages/utils/src/compareArray.ts | 11 +++++++ packages/utils/src/dedupe.ts | 4 +++ packages/utils/src/enumKeys.ts | 26 +++++++++++++++ packages/utils/src/excerpt.ts | 4 +++ packages/utils/src/index.ts | 10 ++++++ packages/utils/src/initialUppercase.ts | 4 +++ packages/utils/src/nTimes.ts | 19 +++++++++++ packages/utils/src/prettyJSON.ts | 17 ++++++++++ packages/utils/src/randomArrayItem.ts | 6 ++++ packages/utils/src/tuplify.ts | 3 ++ 18 files changed, 228 insertions(+) create mode 100644 packages/utils/src/Maybe.ts create mode 100644 packages/utils/src/__tests__/dedupe.test.ts create mode 100644 packages/utils/src/__tests__/enumKeys.test.ts create mode 100644 packages/utils/src/__tests__/excerpt.test.ts create mode 100644 packages/utils/src/__tests__/initialUppercase.test.ts create mode 100644 packages/utils/src/__tests__/nTimes.test.ts create mode 100644 packages/utils/src/__tests__/prettyJSON.test.ts create mode 100644 packages/utils/src/__tests__/randomArrayItem.test.ts create mode 100644 packages/utils/src/compareArray.ts create mode 100644 packages/utils/src/dedupe.ts create mode 100644 packages/utils/src/enumKeys.ts create mode 100644 packages/utils/src/excerpt.ts create mode 100644 packages/utils/src/initialUppercase.ts create mode 100644 packages/utils/src/nTimes.ts create mode 100644 packages/utils/src/prettyJSON.ts create mode 100644 packages/utils/src/randomArrayItem.ts create mode 100644 packages/utils/src/tuplify.ts diff --git a/packages/utils/src/Maybe.ts b/packages/utils/src/Maybe.ts new file mode 100644 index 00000000..2a7563a1 --- /dev/null +++ b/packages/utils/src/Maybe.ts @@ -0,0 +1,2 @@ +type Maybe = T | undefined | null; +export default Maybe; diff --git a/packages/utils/src/__tests__/dedupe.test.ts b/packages/utils/src/__tests__/dedupe.test.ts new file mode 100644 index 00000000..60d7d66d --- /dev/null +++ b/packages/utils/src/__tests__/dedupe.test.ts @@ -0,0 +1,32 @@ +import dedupe from "../dedupe"; + +const tests: Array<{ desc: string; arr: any[]; expect: any[] }> = [ + { + desc: "no duplicates", + arr: ["a", "b", "c", undefined], + expect: ["a", "b", "c", undefined], + }, + { + desc: "array of strings", + arr: ["a", "b", "c", "b"], + expect: ["a", "b", "c"], + }, + { + desc: "array of numbers", + arr: [5, 9, 200, 200, 90, 0, 200], + expect: [5, 9, 200, 90, 0], + }, + { + desc: "array of mixed", + arr: [true, 5, 9, "a", false, 200, 90, 0, "a", 200, true], + expect: [true, 5, 9, "a", false, 200, 90, 0], + }, +]; + +describe("test dedupe", () => { + tests.forEach(test => { + it(`dedupes ${test.desc}`, () => { + expect(dedupe(test.arr)).toEqual(test.expect); + }); + }); +}); diff --git a/packages/utils/src/__tests__/enumKeys.test.ts b/packages/utils/src/__tests__/enumKeys.test.ts new file mode 100644 index 00000000..ac53756b --- /dev/null +++ b/packages/utils/src/__tests__/enumKeys.test.ts @@ -0,0 +1,18 @@ +import enumKeys from "../enumKeys"; + +describe("test enumKeys", () => { + it("iterates through all keys in an enum", () => { + enum TestEnum { + Key1 = "value1", + Key2 = "value2", + Key3 = "value3", + } + + const keys = enumKeys(TestEnum); + const t = keys.map(k => TestEnum[k]); + + expect(t).toContain(TestEnum.Key1); + expect(t).toContain(TestEnum.Key2); + expect(t).toContain(TestEnum.Key3); + }); +}); diff --git a/packages/utils/src/__tests__/excerpt.test.ts b/packages/utils/src/__tests__/excerpt.test.ts new file mode 100644 index 00000000..a0f6d566 --- /dev/null +++ b/packages/utils/src/__tests__/excerpt.test.ts @@ -0,0 +1,11 @@ +import excerpt from "../excerpt"; + +test("does not modify a string shorter than the specified length", () => { + expect(excerpt("shorter string", 100)).toBe("shorter string"); +}); + +test("truncates an excerpt longer than the specified length", () => { + expect(excerpt("there are 38 characters in this string", 30)).toBe( + "there are 38 characters in th…", + ); +}); diff --git a/packages/utils/src/__tests__/initialUppercase.test.ts b/packages/utils/src/__tests__/initialUppercase.test.ts new file mode 100644 index 00000000..01dbc5ad --- /dev/null +++ b/packages/utils/src/__tests__/initialUppercase.test.ts @@ -0,0 +1,7 @@ +import initialUppercase from "../initialUppercase"; + +test("it capitalizes the first letter of a string", () => { + expect(initialUppercase("let's case the HELL out of this sentence!")).toBe( + "Let's case the HELL out of this sentence!", + ); +}); diff --git a/packages/utils/src/__tests__/nTimes.test.ts b/packages/utils/src/__tests__/nTimes.test.ts new file mode 100644 index 00000000..7766fa69 --- /dev/null +++ b/packages/utils/src/__tests__/nTimes.test.ts @@ -0,0 +1,20 @@ +import nTimes, { nTimesWithIndex } from "../nTimes"; + +test("it collects results of doing an operation a given number of times with given args", () => { + const operation = (n: number) => n + 5; + const args = [5]; + const tenTens = nTimes(10, operation, args); + expect(tenTens.length).toBe(10); + expect(tenTens.every(t => t === 10)); +}); + +test("withIndex collects results of doing an operation a given number of times with the index number as an argument", () => { + const operation = (n: number) => `string ${n}`; + expect(nTimesWithIndex(5, operation)).toEqual([ + "string 0", + "string 1", + "string 2", + "string 3", + "string 4", + ]); +}); diff --git a/packages/utils/src/__tests__/prettyJSON.test.ts b/packages/utils/src/__tests__/prettyJSON.test.ts new file mode 100644 index 00000000..1107a802 --- /dev/null +++ b/packages/utils/src/__tests__/prettyJSON.test.ts @@ -0,0 +1,23 @@ +import prettyJSON, { prettyJSONText } from "../prettyJSON"; + +const expected = `{ + "foo": "bar", + "baz": { + "boom": 123 + } +}`; + +test("renders JSON in a readable format", () => { + const data = { foo: "bar", baz: { boom: 123 } }; + expect(prettyJSON(data)).toBe(expected); +}); + +test("parses and renders JSON text in a readable format", () => { + const data = `{ "foo": "bar", "baz": { "boom": 123 } }`; + expect(prettyJSONText(data)).toBe(expected); +}); + +test("bad json returns original string", () => { + const data = `{ "foo": bad, "baz": { "boom": 123 } }`; + expect(prettyJSONText(data)).toBe(data); +}); diff --git a/packages/utils/src/__tests__/randomArrayItem.test.ts b/packages/utils/src/__tests__/randomArrayItem.test.ts new file mode 100644 index 00000000..7dc8991d --- /dev/null +++ b/packages/utils/src/__tests__/randomArrayItem.test.ts @@ -0,0 +1,11 @@ +import nTimes from "../nTimes"; +import randomArrayItem from "../randomArrayItem"; + +test("it returns an item from the array", () => { + const items = [1, 2, 3, 4, 5]; + nTimes(10, () => expect(items).toContain(randomArrayItem(items))); +}); + +test("it throws when the passed array is empty", () => { + expect(() => randomArrayItem([])).toThrow(); +}); diff --git a/packages/utils/src/compareArray.ts b/packages/utils/src/compareArray.ts new file mode 100644 index 00000000..8e32401a --- /dev/null +++ b/packages/utils/src/compareArray.ts @@ -0,0 +1,11 @@ +export default function compareArray(arr1: string[], arr2: string[]): boolean { + if (arr1.length !== arr2.length) return false; + const sortedArr1 = arr1.sort(); + const sortedArr2 = arr2.sort(); + for (let i = 0; i < arr1.length; i++) { + if (sortedArr1[i] !== sortedArr2[i]) { + return false; + } + } + return true; +} diff --git a/packages/utils/src/dedupe.ts b/packages/utils/src/dedupe.ts new file mode 100644 index 00000000..50b875a5 --- /dev/null +++ b/packages/utils/src/dedupe.ts @@ -0,0 +1,4 @@ +// Removes duplicates from array for primitive types +export default function dedupe(arr: T[]): T[] { + return Array.from(new Set(arr)); +} diff --git a/packages/utils/src/enumKeys.ts b/packages/utils/src/enumKeys.ts new file mode 100644 index 00000000..4692455d --- /dev/null +++ b/packages/utils/src/enumKeys.ts @@ -0,0 +1,26 @@ +/** + * enumKeys takes an enum and returns an array of strongly typed keys. Useful to iterate over string enums. + * @param obj The enum + * @returns An array of strong typed keys + * @example + * + * enum MyEnum { + * Key1 = "value1", + * Key2 = "value2", + * Key3 = "value3" + * } + * + * // ks's type is ("Key1" || "Key2" || "Key3")[] + * const ks = enumKeys(MyEnum); + * + * // keys' type is MyEnum[] + * // Unfortunately, we can't do this inside enumKeys because there is no generic type constraint for enums. + * const keys = ks.map(k => MyEnum[k]) + */ +function enumKeys(obj: O): K[] { + return Object.keys(obj) + .filter(k => Number.isNaN(+k)) + .map(k => k as K); +} + +export default enumKeys; diff --git a/packages/utils/src/excerpt.ts b/packages/utils/src/excerpt.ts new file mode 100644 index 00000000..1b09253d --- /dev/null +++ b/packages/utils/src/excerpt.ts @@ -0,0 +1,4 @@ +const truncate = (str: string, len: number) => `${str.slice(0, len - 1)}…`; + +export default (str: string, len = 100) => + str.length < len ? str : truncate(str, len); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 41f9e25a..5cdbd98b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1 +1,11 @@ +export { default as Maybe } from "./Maybe"; +export { default as compareArray } from "./compareArray"; +export { default as dedupe } from "./dedupe"; +export { default as enumKeys } from "./enumKeys"; +export { default as excerpt } from "./excerpt"; +export { default as initialUppercase } from "./initialUppercase"; +export { default as nTimes, nTimesWithIndex } from "./nTimes"; +export { default as prettyJSON, prettyJSONText } from "./prettyJSON"; +export { default as randomArrayItem } from "./randomArrayItem"; export { default as safeJSONParse } from "./safeJSONParse"; +export { default as tuplify } from "./tuplify"; diff --git a/packages/utils/src/initialUppercase.ts b/packages/utils/src/initialUppercase.ts new file mode 100644 index 00000000..80c622e3 --- /dev/null +++ b/packages/utils/src/initialUppercase.ts @@ -0,0 +1,4 @@ +export default function initialUppercase(s: string): string { + const [first, ...rest] = s; + return [first.toLocaleUpperCase(), ...rest].join(""); +} diff --git a/packages/utils/src/nTimes.ts b/packages/utils/src/nTimes.ts new file mode 100644 index 00000000..c9f6fd62 --- /dev/null +++ b/packages/utils/src/nTimes.ts @@ -0,0 +1,19 @@ +export default function nTimes( + n: number, + operation: (...args: any[]) => T, + args: Parameters = [], +): T[] { + const collection = []; + for (let i = 0; i < n; i++) { + collection.push(operation(...args)); + } + return collection; +} + +export function nTimesWithIndex(n: number, operation: (n: number) => T) { + const collection = []; + for (let i = 0; i < n; i++) { + collection.push(operation(i)); + } + return collection; +} diff --git a/packages/utils/src/prettyJSON.ts b/packages/utils/src/prettyJSON.ts new file mode 100644 index 00000000..1372210b --- /dev/null +++ b/packages/utils/src/prettyJSON.ts @@ -0,0 +1,17 @@ +export default function (data: any): string { + try { + return JSON.stringify(data, null, 2); + } catch (err) { + console.error(err); + return `${data}`; + } +} + +export function prettyJSONText(text: string): string { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch (err) { + console.error(err); + return text; + } +} diff --git a/packages/utils/src/randomArrayItem.ts b/packages/utils/src/randomArrayItem.ts new file mode 100644 index 00000000..4bff253a --- /dev/null +++ b/packages/utils/src/randomArrayItem.ts @@ -0,0 +1,6 @@ +export default function randomArrayItem(arr: T[]): T { + if (arr.length === 0) { + throw new Error("Can't take random element of empty array"); + } + return arr[Math.floor(Math.random() * arr.length)]; +} diff --git a/packages/utils/src/tuplify.ts b/packages/utils/src/tuplify.ts new file mode 100644 index 00000000..95c3c3c3 --- /dev/null +++ b/packages/utils/src/tuplify.ts @@ -0,0 +1,3 @@ +export default function tuplify(...elements: T) { + return elements; +} From c4f6dc935467908822dd11be587fdf96ff4d5000 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 11:15:21 -0800 Subject: [PATCH 04/11] Publish, readme --- packages/hooks/package.json | 1 + packages/utils/README.md | 15 ++++ packages/utils/package.json | 2 + yarn.lock | 155 ++++++++++++++++++------------------ 4 files changed, 96 insertions(+), 77 deletions(-) create mode 100644 packages/utils/README.md diff --git a/packages/hooks/package.json b/packages/hooks/package.json index f729da12..23f70534 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,7 @@ { "name": "@dolthub/react-hooks", "author": "DoltHub", + "description": "A collection of React hooks for common tasks", "version": "0.1.6", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/utils/README.md b/packages/utils/README.md new file mode 100644 index 00000000..1b3fd5e3 --- /dev/null +++ b/packages/utils/README.md @@ -0,0 +1,15 @@ +# @dolthub/web-utils + +A library of useful utilities for the web. + +## Installation + +``` +% yarn add @dolthub/web-utils +``` + +or + +``` +% npm install @dolthub/web-utils +``` diff --git a/packages/utils/package.json b/packages/utils/package.json index 31081616..a3a98a94 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,7 @@ { "name": "@dolthub/web-utils", "author": "DoltHub", + "description": "A collection of utilities for building web applications", "version": "0.1.0", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -21,6 +22,7 @@ "lint": "eslint --cache --ext .ts,.js,.tsx,.jsx src", "prettier": "prettier --check 'src/**/*.{js,ts}'", "prettier-fix": "prettier --write 'src/**/*.{js,ts}'", + "publish": "yarn dbuild && npm publish", "test": "jest", "yalc:publish": "yarn dbuild && yalc publish", "yalc:push": "yarn dbuild && yalc push" diff --git a/yarn.lock b/yarn.lock index 7e845b0c..d1942a38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1983,9 +1983,9 @@ __metadata: linkType: hard "@pkgr/core@npm:^0.1.0": - version: 0.1.0 - resolution: "@pkgr/core@npm:0.1.0" - checksum: 8f4a0aa6cc1c445fec4f5f12157047e8a05e30b5c03441156f40203d6430f84d15135e8f1a6886e6c9800ff0e4a75d9d3419a43dbcd7490683f2882375a3b99a + version: 0.1.1 + resolution: "@pkgr/core@npm:0.1.1" + checksum: 3f7536bc7f57320ab2cf96f8973664bef624710c403357429fbf680a5c3b4843c1dbd389bb43daa6b1f6f1f007bb082f5abcb76bb2b5dc9f421647743b71d3d8 languageName: node linkType: hard @@ -2423,11 +2423,11 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 20.11.0 - resolution: "@types/node@npm:20.11.0" + version: 20.11.4 + resolution: "@types/node@npm:20.11.4" dependencies: undici-types: "npm:~5.26.4" - checksum: 560aa850dfccb83326f9cba125459f6c3fb0c71ec78f22c61e4d248f1df78bd25fd6792cef573dfbdc49c882f8e38bb1a82ca87e0e28ff2513629c704c2b02af + checksum: af6e1ad4d8f210d06d6db6c85b9aba25b022b493a4f2289e5ac693b418de746db812ab1537096e10e7cb7b78f1d835a06e61f31d93ebe36ef116bfbbf8f04c97 languageName: node linkType: hard @@ -2448,13 +2448,13 @@ __metadata: linkType: hard "@types/react@npm:*, @types/react@npm:^18": - version: 18.2.47 - resolution: "@types/react@npm:18.2.47" + version: 18.2.48 + resolution: "@types/react@npm:18.2.48" dependencies: "@types/prop-types": "npm:*" "@types/scheduler": "npm:*" csstype: "npm:^3.0.2" - checksum: e98ea1827fe60636d0f7ce206397159a29fc30613fae43e349e32c10ad3c0b7e0ed2ded2f3239e07bd5a3cba8736b6114ba196acccc39905ca4a06f56a8d2841 + checksum: 7e89f18ea2928b1638f564b156d692894dcb9352a7e0a807873c97e858abe1f23dbd165a25dd088a991344e973fdeef88ba5724bfb64504b74072cbc9c220c3a languageName: node linkType: hard @@ -2520,14 +2520,14 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/eslint-plugin@npm:6.18.1" + version: 6.19.0 + resolution: "@typescript-eslint/eslint-plugin@npm:6.19.0" dependencies: "@eslint-community/regexpp": "npm:^4.5.1" - "@typescript-eslint/scope-manager": "npm:6.18.1" - "@typescript-eslint/type-utils": "npm:6.18.1" - "@typescript-eslint/utils": "npm:6.18.1" - "@typescript-eslint/visitor-keys": "npm:6.18.1" + "@typescript-eslint/scope-manager": "npm:6.19.0" + "@typescript-eslint/type-utils": "npm:6.19.0" + "@typescript-eslint/utils": "npm:6.19.0" + "@typescript-eslint/visitor-keys": "npm:6.19.0" debug: "npm:^4.3.4" graphemer: "npm:^1.4.0" ignore: "npm:^5.2.4" @@ -2540,44 +2540,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: fbcfae9b92f35ce10212f44f43f93c43f6eb3e28a571da7ed0d424396916aaf080f16ce91a5bffb9e1b42ca2d6003a3e2ad65131b4ef72ed2f94a4bedb35a735 + checksum: ab1a5ace6663b0c6d2418e321328fa28aa4bdc4b5fae257addec01346fb3a9c2d3a2960ade0f7114e6974c513a28632c9e8e602333cc0fab3135c445babdef59 languageName: node linkType: hard "@typescript-eslint/parser@npm:^6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/parser@npm:6.18.1" + version: 6.19.0 + resolution: "@typescript-eslint/parser@npm:6.19.0" dependencies: - "@typescript-eslint/scope-manager": "npm:6.18.1" - "@typescript-eslint/types": "npm:6.18.1" - "@typescript-eslint/typescript-estree": "npm:6.18.1" - "@typescript-eslint/visitor-keys": "npm:6.18.1" + "@typescript-eslint/scope-manager": "npm:6.19.0" + "@typescript-eslint/types": "npm:6.19.0" + "@typescript-eslint/typescript-estree": "npm:6.19.0" + "@typescript-eslint/visitor-keys": "npm:6.19.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 78cf87c49be224a7fc7c9b1580b015b79e6f6b78d3db60843825b9657e6c5b852566ca7fcb9a51e7b781e910a89a73cdc36dfcd180ccb34febc535ad9b5a0be1 + checksum: d547bfb1aaed112cfc0f9f0be8506a280952ba3b61be42b749352139361bd94e4a47fa043d819e19c6a498cacbd8bb36a46e3628c436a7e2009e7ac27afc8861 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/scope-manager@npm:6.18.1" +"@typescript-eslint/scope-manager@npm:6.19.0": + version: 6.19.0 + resolution: "@typescript-eslint/scope-manager@npm:6.19.0" dependencies: - "@typescript-eslint/types": "npm:6.18.1" - "@typescript-eslint/visitor-keys": "npm:6.18.1" - checksum: 66ef86688a2eb69988a15d6c0176e5e1ec3994ab96526ca525226a1815eef63366e10e3e6a041ceb2cd63d1cced27874d2313045b785418330af68a288e50771 + "@typescript-eslint/types": "npm:6.19.0" + "@typescript-eslint/visitor-keys": "npm:6.19.0" + checksum: 1ec7b9dedca7975f0aa4543c1c382f7d6131411bd443a5f9b96f137acb6adb450888ed13c95f6d26546b682b2e0579ce8a1c883fdbe2255dc0b61052193b8243 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/type-utils@npm:6.18.1" +"@typescript-eslint/type-utils@npm:6.19.0": + version: 6.19.0 + resolution: "@typescript-eslint/type-utils@npm:6.19.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:6.18.1" - "@typescript-eslint/utils": "npm:6.18.1" + "@typescript-eslint/typescript-estree": "npm:6.19.0" + "@typescript-eslint/utils": "npm:6.19.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.0.1" peerDependencies: @@ -2585,23 +2585,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 5198752a51649afd960205708c4d765e0170a46a1eb96c97e706890fecb2642933a6377337cf3632f9737915da0201607872a46c9c551d1accf9176b0e025023 + checksum: 5b146b985481e587122026c703ac9f537ad7e90eee1dca814971bca0d7e4a5d4ff9861fb4bf749014c28c6a4fbb4a01a4527355961315eb9501f3569f8e8dd38 languageName: node linkType: hard -"@typescript-eslint/types@npm:6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/types@npm:6.18.1" - checksum: 58c1a1bcf2403891a4fcb0d21aac643a6f9d06119423230dad74ef2b95adf94201da7cf48617b0c27b51695225b622e48c739cf4186ef5f99294887d2d536557 +"@typescript-eslint/types@npm:6.19.0": + version: 6.19.0 + resolution: "@typescript-eslint/types@npm:6.19.0" + checksum: 6f81860a3c14df55232c2e6dec21fb166867b9f30b3c3369b325aef5ee1c7e41e827c0504654daa49c8ff1a3a9ca9d9bfe76786882b6212a7c1b58991a9c80b9 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/typescript-estree@npm:6.18.1" +"@typescript-eslint/typescript-estree@npm:6.19.0": + version: 6.19.0 + resolution: "@typescript-eslint/typescript-estree@npm:6.19.0" dependencies: - "@typescript-eslint/types": "npm:6.18.1" - "@typescript-eslint/visitor-keys": "npm:6.18.1" + "@typescript-eslint/types": "npm:6.19.0" + "@typescript-eslint/visitor-keys": "npm:6.19.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2611,34 +2611,34 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 5bca8f58d3134c5296c7e6cbeef512feb3918cdc88b5b22e656a7978277278e7a86187690e7e3be3f3708feb98c952a6ab4d8bbc197fff3826e3afa8bc1e287e + checksum: 5b365f009e43c7beafdbb7d8ecad78ee1087b0a4338cd9ec695eed514b7b4c1089e56239761139ddae629ec0ce8d428840c6ebfeea3618d2efe00c84f8794da5 languageName: node linkType: hard -"@typescript-eslint/utils@npm:6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/utils@npm:6.18.1" +"@typescript-eslint/utils@npm:6.19.0": + version: 6.19.0 + resolution: "@typescript-eslint/utils@npm:6.19.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" "@types/json-schema": "npm:^7.0.12" "@types/semver": "npm:^7.5.0" - "@typescript-eslint/scope-manager": "npm:6.18.1" - "@typescript-eslint/types": "npm:6.18.1" - "@typescript-eslint/typescript-estree": "npm:6.18.1" + "@typescript-eslint/scope-manager": "npm:6.19.0" + "@typescript-eslint/types": "npm:6.19.0" + "@typescript-eslint/typescript-estree": "npm:6.19.0" semver: "npm:^7.5.4" peerDependencies: eslint: ^7.0.0 || ^8.0.0 - checksum: b9dcb2fa7cc8c46254c22fee190032320a5dd8ce282fb01e99cb35da6c00e33b157f4285b062d841942e9aad1d7ce1a16aaa46dd05ca7d81de706aedbbfff396 + checksum: 343ff4cd4f7e102df8c46b41254d017a33d95df76455531fda679fdb92aebb9c111df8ee9ab54972e73c1e8fad9dd7e421001233f0aee8115384462b0821852e languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:6.18.1": - version: 6.18.1 - resolution: "@typescript-eslint/visitor-keys@npm:6.18.1" +"@typescript-eslint/visitor-keys@npm:6.19.0": + version: 6.19.0 + resolution: "@typescript-eslint/visitor-keys@npm:6.19.0" dependencies: - "@typescript-eslint/types": "npm:6.18.1" + "@typescript-eslint/types": "npm:6.19.0" eslint-visitor-keys: "npm:^3.4.1" - checksum: f3dacdd1db7347908ac207968da4fa72efb31e38a6dde652651633c5283f054832045f2ad00b4ca7478e7f2e09fe4ae6e3a32b76580c036b9e5c7b8dd55af9f3 + checksum: bb34e922e018aadf34866995ea5949d6623f184cc4f6470ab05767dd208ffabb003b7dc3872199714574b7f10afe89d49c6f89a4e8d086edea82be73e189f1bb languageName: node linkType: hard @@ -3192,9 +3192,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001565": - version: 1.0.30001576 - resolution: "caniuse-lite@npm:1.0.30001576" - checksum: 79cf666f9139c542bdf75eab76171534dc638d2f8efacd325649c8ec6be59de400f0e9d6dc02504f12125626b306c0a848fe86904c01722218b2a479be82a9c1 + version: 1.0.30001577 + resolution: "caniuse-lite@npm:1.0.30001577" + checksum: 3f94957756227c39477ac3498aa8e975e5e22a24680d49f3cb85239e21b31012e436958621508ffa240201e356e1a680428be320732fc69a8de7163756db42b8 languageName: node linkType: hard @@ -3656,9 +3656,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.4.601": - version: 1.4.630 - resolution: "electron-to-chromium@npm:1.4.630" - checksum: dbe3b3437b00f978aa261e1ee9c80eebaba70dea601866b0d64e36f7146328c79bce8416652e4713cfeb0e22428cc7ec1751881860ec892fa32ec8de015d1a9e + version: 1.4.633 + resolution: "electron-to-chromium@npm:1.4.633" + checksum: 56dd45a862641c9f11147c07cf0f3a465e1571dbad04f7d9acb1e8262b3391ede24acb6a0c777998cb8931f54b2b61e8dd4755e754fe4c0782b1fa587b7d3475 languageName: node linkType: hard @@ -4635,7 +4635,7 @@ __metadata: languageName: node linkType: hard -"has-property-descriptors@npm:^1.0.0": +"has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.1": version: 1.0.1 resolution: "has-property-descriptors@npm:1.0.1" dependencies: @@ -6671,11 +6671,11 @@ __metadata: linkType: hard "prettier@npm:^3.1.0": - version: 3.2.1 - resolution: "prettier@npm:3.2.1" + version: 3.2.2 + resolution: "prettier@npm:3.2.2" bin: prettier: bin/prettier.cjs - checksum: e01284f25c1e9a96dfaf4f7d0bfdf0726b26afc732b0e645a2653174d4ad5a1e85ae21ec4d327c71536f1acf8fee1dff5ea35c5b4b8e5b83ff00fe1dd6fef146 + checksum: e84d0d2a4ce2b88ee1636904effbdf68b59da63d9f887128f2ed5382206454185432e7c0a9578bc4308bc25d099cfef47fd0b9c211066777854e23e65e34044d languageName: node linkType: hard @@ -7213,14 +7213,14 @@ __metadata: linkType: hard "safe-array-concat@npm:^1.0.1": - version: 1.0.1 - resolution: "safe-array-concat@npm:1.0.1" + version: 1.1.0 + resolution: "safe-array-concat@npm:1.1.0" dependencies: - call-bind: "npm:^1.0.2" - get-intrinsic: "npm:^1.2.1" + call-bind: "npm:^1.0.5" + get-intrinsic: "npm:^1.2.2" has-symbols: "npm:^1.0.3" isarray: "npm:^2.0.5" - checksum: 4b15ce5fce5ce4d7e744a63592cded88d2f27806ed229eadb2e42629cbcd40e770f7478608e75f455e7fe341acd8c0a01bdcd7146b10645ea7411c5e3c1d1dd8 + checksum: 833d3d950fc7507a60075f9bfaf41ec6dac7c50c7a9d62b1e6b071ecc162185881f92e594ff95c1a18301c881352dd6fd236d56999d5819559db7b92da9c28af languageName: node linkType: hard @@ -7297,14 +7297,15 @@ __metadata: linkType: hard "set-function-length@npm:^1.1.1": - version: 1.1.1 - resolution: "set-function-length@npm:1.1.1" + version: 1.2.0 + resolution: "set-function-length@npm:1.2.0" dependencies: define-data-property: "npm:^1.1.1" - get-intrinsic: "npm:^1.2.1" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.2" gopd: "npm:^1.0.1" - has-property-descriptors: "npm:^1.0.0" - checksum: a29e255c116c29e3323b851c4f46c58c91be9bb8b065f191e2ea1807cb2c839df56e3175732a498e0c6d54626ba6b6fef896bf699feb7ab70c42dc47eb247c95 + has-property-descriptors: "npm:^1.0.1" + checksum: b4fdf68bbfa9944284a9469c04e0d9cdb7924942fab75cd11fb61e8a7518f0d40bbbbc1b46871f648a93b97d170d8047fe3492cdadff066a8a8ae4ce68d0564a languageName: node linkType: hard From 0e071cd8104cc2feb3fbe3f84b51125de71b22c7 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 11:28:19 -0800 Subject: [PATCH 05/11] hooks: Fix compile --- packages/hooks/tsconfig.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/hooks/tsconfig.json b/packages/hooks/tsconfig.json index 4654b109..685ef1f0 100644 --- a/packages/hooks/tsconfig.json +++ b/packages/hooks/tsconfig.json @@ -20,5 +20,8 @@ "composite": true, "noImplicitAny": true, "types": ["node", "jsdom", "@testing-library/jest-dom"] - } + }, + "references": [ + { "path": "../utils" } + ] } \ No newline at end of file From e938a1bcd7bce6b6b261c2f0b057f532f4e310b7 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 11:54:47 -0800 Subject: [PATCH 06/11] Actually fix CI --- package.json | 5 ++--- packages/hooks/package.json | 10 +++++----- yarn.lock | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 0817786f..d0fa9ca2 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,9 @@ "packages/*" ], "scripts": { - "ci": "yarn prettier && yarn compile && yarn lint && yarn test && yarn build", + "ci": "yarn prettier && yarn lint && yarn dbuild && yarn test", "clean": "rimraf node_modules -g 'packages/*/.eslintcache' 'packages/*/*.tsbuildinfo' 'packages/*/dist' 'packages/*/.rollup.cache' 'packages/*/types' 'packages/*/coverage'", - "compile": "yarn workspace @dolthub/react-hooks compile && yarn workspace @dolthub/web-utils compile", - "build": "yarn workspace @dolthub/react-hooks build && yarn workspace @dolthub/web-utils build", + "dbuild": "yarn workspace @dolthub/web-utils dbuild && yarn workspace @dolthub/react-hooks dbuild", "lint": "yarn workspace @dolthub/react-hooks lint && yarn workspace @dolthub/web-utils lint", "prettier": "yarn workspace @dolthub/react-hooks prettier && yarn workspace @dolthub/web-utils prettier", "test": "yarn workspace @dolthub/react-hooks test && yarn workspace @dolthub/web-utils test" diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 23f70534..40842b4c 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -32,12 +32,16 @@ "react": "^18.2.0", "react-dom": "^18.2.0" }, + "dependencies": { + "@dolthub/web-utils": "0.1.0", + "js-cookie": "^3.0.5", + "react-hotkeys": "^2.0.0" + }, "devDependencies": { "@babel/core": "^7.23.7", "@babel/preset-env": "^7.23.8", "@babel/preset-react": "^7.23.3", "@babel/preset-typescript": "^7.23.3", - "@dolthub/web-utils": "^0.1.0", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.5", @@ -76,9 +80,5 @@ }, "bugs": { "url": "https://github.com/dolthub/react-library/issues" - }, - "dependencies": { - "js-cookie": "^3.0.5", - "react-hotkeys": "^2.0.0" } } diff --git a/yarn.lock b/yarn.lock index d1942a38..452d0199 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1475,7 +1475,7 @@ __metadata: "@babel/preset-env": "npm:^7.23.8" "@babel/preset-react": "npm:^7.23.3" "@babel/preset-typescript": "npm:^7.23.3" - "@dolthub/web-utils": "npm:^0.1.0" + "@dolthub/web-utils": "npm:0.1.0" "@rollup/plugin-commonjs": "npm:^25.0.7" "@rollup/plugin-node-resolve": "npm:^15.2.3" "@rollup/plugin-typescript": "npm:^11.1.5" @@ -1515,7 +1515,7 @@ __metadata: languageName: unknown linkType: soft -"@dolthub/web-utils@npm:^0.1.0, @dolthub/web-utils@workspace:packages/utils": +"@dolthub/web-utils@npm:0.1.0, @dolthub/web-utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@dolthub/web-utils@workspace:packages/utils" dependencies: From 267a22a808251dd1acb4d1b5faaf20c57caf1c78 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 13:23:40 -0800 Subject: [PATCH 07/11] Add more utils --- packages/utils/package.json | 3 + packages/utils/rollup.config.js | 7 +- .../src/__tests__/dateConversions.test.ts | 38 +++ .../utils/src/__tests__/pluralize.test.ts | 19 ++ packages/utils/src/__tests__/urlUtils.test.ts | 217 ++++++++++++++ packages/utils/src/dateConversions.ts | 79 +++++ packages/utils/src/excerpt.ts | 8 +- packages/utils/src/fakeEscapePress.ts | 24 ++ packages/utils/src/index.ts | 18 ++ packages/utils/src/pluralize.ts | 23 ++ packages/utils/src/prettyJSON.ts | 2 +- packages/utils/src/randomNum.ts | 3 + packages/utils/src/urlUtils.ts | 270 ++++++++++++++++++ yarn.lock | 8 + 14 files changed, 712 insertions(+), 7 deletions(-) create mode 100644 packages/utils/src/__tests__/dateConversions.test.ts create mode 100644 packages/utils/src/__tests__/pluralize.test.ts create mode 100644 packages/utils/src/__tests__/urlUtils.test.ts create mode 100644 packages/utils/src/dateConversions.ts create mode 100644 packages/utils/src/fakeEscapePress.ts create mode 100644 packages/utils/src/pluralize.ts create mode 100644 packages/utils/src/randomNum.ts create mode 100644 packages/utils/src/urlUtils.ts diff --git a/packages/utils/package.json b/packages/utils/package.json index a3a98a94..2a99cfdf 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -56,5 +56,8 @@ }, "bugs": { "url": "https://github.com/dolthub/react-library/issues" + }, + "dependencies": { + "timeago.js": "^4.0.2" } } diff --git a/packages/utils/rollup.config.js b/packages/utils/rollup.config.js index f58bddd6..cc143bd6 100644 --- a/packages/utils/rollup.config.js +++ b/packages/utils/rollup.config.js @@ -1,4 +1,4 @@ -import resolve from "@rollup/plugin-node-resolve"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import typescript from "@rollup/plugin-typescript"; import { terser } from "rollup-plugin-terser"; @@ -23,11 +23,12 @@ export default [ sourcemap: true, }, ], + external: ["querystring", "url"], plugins: [ external(), - resolve(), + nodeResolve(), commonjs(), - typescript({ tsconfig: "./tsconfig.json" }), + typescript({ tsconfig: "./tsconfig.json", outputToFilesystem: true }), terser(), ], }, diff --git a/packages/utils/src/__tests__/dateConversions.test.ts b/packages/utils/src/__tests__/dateConversions.test.ts new file mode 100644 index 00000000..019a3b55 --- /dev/null +++ b/packages/utils/src/__tests__/dateConversions.test.ts @@ -0,0 +1,38 @@ +// const dateTests: Array<{ +// date: Date; +// expectedDate: string; +// }> = [ +// { +// date: getDateAtTime(1, 2, 4, 30), +// expectedDate: "2022-01-02T04:30", +// }, +// { +// date: getDateAtTime(9, 30, 22, 1), +// expectedDate: "2022-09-30T22:01", +// }, +// { +// date: getDateAtTime(12, 31, 23, 58), +// expectedDate: "2022-12-31T23:58", +// }, +// ]; + +describe("test date conversions", () => { + // dateTests.forEach(test => { + // it(`gets date and time string for ${test.date.toLocaleDateString()}`, () => { + // expect(getDateAndTimeString(test.date)).toEqual(test.expectedDate); + // }); + // }); +}); + +// function getDateAtTime( +// month: number, +// day: number, +// hours: number, +// minutes: number, +// ): Date { +// const date = new Date(); +// // Months are 0 indexed +// date.setFullYear(2022, month - 1, day); +// date.setHours(hours, minutes); +// return date; +// } diff --git a/packages/utils/src/__tests__/pluralize.test.ts b/packages/utils/src/__tests__/pluralize.test.ts new file mode 100644 index 00000000..5b057fb1 --- /dev/null +++ b/packages/utils/src/__tests__/pluralize.test.ts @@ -0,0 +1,19 @@ +import pluralize from "../pluralize"; + +describe("test pluralize", () => { + it("pluralizes words", () => { + expect(pluralize(1, "test")).toEqual("test"); + expect(pluralize(2, "test")).toEqual("tests"); + expect(pluralize(3, "person")).toEqual("people"); + expect(pluralize(0, "repository")).toEqual("repositories"); + expect(pluralize(10, "repository updated today")).toEqual( + "repositories updated today", + ); + expect(pluralize(0, "database updated today")).toEqual( + "databases updated today", + ); + expect(pluralize(10, "database updated today")).toEqual( + "databases updated today", + ); + }); +}); diff --git a/packages/utils/src/__tests__/urlUtils.test.ts b/packages/utils/src/__tests__/urlUtils.test.ts new file mode 100644 index 00000000..0f690907 --- /dev/null +++ b/packages/utils/src/__tests__/urlUtils.test.ts @@ -0,0 +1,217 @@ +import { LinkObject, queryHandler, Route } from "../urlUtils"; + +describe("test queryHandler util function", () => { + const tests = [ + { desc: "no values", q: {}, exp: {} }, + { desc: "one undefined value", q: { refName: undefined }, exp: {} }, + { + desc: "undefined, defined value", + q: { refName: undefined, active: "true" }, + exp: { active: "true" }, + }, + { + desc: "empty string, defined value", + q: { refName: "", active: "true" }, + exp: { active: "true" }, + }, + { + desc: "two defined values", + q: { refName: "main", active: "true" }, + exp: { refName: "main", active: "true" }, + }, + ]; + + tests.forEach(test => { + it(test.desc, () => { + expect(queryHandler(test.q)).toEqual(test.exp); + }); + }); +}); + +describe("test Route class and methods", () => { + const strLink = new Route("/repositories"); + const refQuery = { + refName: "main", + }; + const urlLink = strLink.addDynamic("ownerName", "taylor").withQuery(refQuery); + + const tests: Array<{ desc: string; link: Route; exp: LinkObject }> = [ + { + desc: "String link, no adds", + link: strLink, + exp: { href: "/repositories", as: "/repositories" }, + }, + { + desc: "String link, add static str", + link: strLink.addStatic("profile"), + exp: { href: "/repositories/profile", as: "/repositories/profile" }, + }, + { + desc: "String link, add dynamic str", + link: strLink.addDynamic("ownerName", "taylor"), + exp: { href: "/repositories/[ownerName]", as: "/repositories/taylor" }, + }, + { + desc: "String link, add static str with query", + link: strLink.addStatic("database").withQuery(refQuery), + exp: { + href: { pathname: "/repositories/database", query: refQuery, hash: "" }, + as: { pathname: "/repositories/database", query: refQuery, hash: "" }, + }, + }, + { + desc: "String link, add dynamic str with query", + link: strLink.addDynamic("ownerName", "taylor").withQuery(refQuery), + exp: { + href: { + pathname: "/repositories/[ownerName]", + query: refQuery, + hash: "", + }, + as: { pathname: "/repositories/taylor", query: refQuery, hash: "" }, + }, + }, + { + desc: "String link, add dynamic str with query and hash", + link: strLink + .addDynamic("ownerName", "taylor") + .withQuery(refQuery) + .withHash("commithash"), + exp: { + href: { + pathname: "/repositories/[ownerName]", + query: refQuery, + hash: "commithash", + }, + as: { + pathname: "/repositories/taylor", + query: refQuery, + hash: "commithash", + }, + }, + }, + { + desc: "String link, add dynamic str with hash and query", + link: strLink + .addDynamic("ownerName", "taylor") + .withHash("commithash") + .withQuery(refQuery), + exp: { + href: { + pathname: "/repositories/[ownerName]", + query: refQuery, + hash: "commithash", + }, + as: { + pathname: "/repositories/taylor", + query: refQuery, + hash: "commithash", + }, + }, + }, + { + desc: "UrlObject link, no adds", + link: urlLink, + exp: { + href: { + pathname: "/repositories/[ownerName]", + query: refQuery, + hash: "", + }, + as: { pathname: "/repositories/taylor", query: refQuery, hash: "" }, + }, + }, + { + desc: "UrlObject link, add static str", + link: urlLink.addStatic("commits"), + exp: { + href: "/repositories/[ownerName]/commits", + as: "/repositories/taylor/commits", + }, + }, + { + desc: "UrlObject link, add dynamic str", + link: urlLink.addDynamic("databaseName", "my-data"), + exp: { + href: "/repositories/[ownerName]/[databaseName]", + as: "/repositories/taylor/my-data", + }, + }, + { + desc: "UrlObject link, add static str with query", + link: urlLink.addStatic("commits").withQuery({ + ...refQuery, + active: "Tables", + }), + exp: { + href: { + pathname: "/repositories/[ownerName]/commits", + query: { ...refQuery, active: "Tables" }, + hash: "", + }, + as: { + pathname: "/repositories/taylor/commits", + query: { ...refQuery, active: "Tables" }, + hash: "", + }, + }, + }, + { + desc: "UrlObject link, add static str and dynamic str with query", + link: urlLink + .addStatic("commits") + .addDynamic("refName", "main") + .withQuery({ active: "Tables" }), + exp: { + href: { + pathname: "/repositories/[ownerName]/commits/[refName]", + query: { active: "Tables" }, + hash: "", + }, + as: { + pathname: "/repositories/taylor/commits/main", + query: { active: "Tables" }, + hash: "", + }, + }, + }, + { + desc: "UrlObject link, add static str with hash", + link: urlLink.addStatic("commits").withHash("commithash"), + exp: { + href: { + pathname: "/repositories/[ownerName]/commits", + hash: "commithash", + }, + as: { + pathname: "/repositories/taylor/commits", + hash: "commithash", + }, + }, + }, + { + desc: "UrlObject link, add static str and dynamic str with hash", + link: urlLink + .addStatic("commits") + .addDynamic("refName", "main") + .withHash("commithash"), + exp: { + href: { + pathname: "/repositories/[ownerName]/commits/[refName]", + hash: "commithash", + }, + as: { + pathname: "/repositories/taylor/commits/main", + hash: "commithash", + }, + }, + }, + ]; + + tests.forEach(test => { + it(test.desc, () => { + expect(test.link.href).toEqual(test.exp.href); + expect(test.link.as).toEqual(test.exp.as); + }); + }); +}); diff --git a/packages/utils/src/dateConversions.ts b/packages/utils/src/dateConversions.ts new file mode 100644 index 00000000..e13a9aba --- /dev/null +++ b/packages/utils/src/dateConversions.ts @@ -0,0 +1,79 @@ +import { format } from "timeago.js"; + +// TIME IN MS +const oneSecond = 1000; +const oneMinute = oneSecond * 60; +export const oneHour = oneMinute * 60; + +export function getUTCDateAndTimeString(date: Date): string { + const d = getUTCDateString(date); + const t = `${getUTCTimeString(date)}:${zeroPadNumber(date.getUTCSeconds())}`; + return `${d} ${t}`; +} + +export function getDateString(date: Date): string { + const month = zeroPadNumber(date.getMonth() + 1); // January is 0 + const day = zeroPadNumber(date.getDate()); + const year = date.getFullYear(); + return `${year}-${month}-${day}`; +} + +export function getUTCDateString(date: Date): string { + const month = zeroPadNumber(date.getUTCMonth() + 1); // January is 0 + const day = zeroPadNumber(date.getUTCDate()); + const year = date.getUTCFullYear(); + return `${year}-${month}-${day}`; +} + +export function getUTCTimeString(date: Date): string { + const hour = date.getUTCHours(); + const mins = date.getUTCMinutes(); + return `${zeroPadNumber(hour)}:${zeroPadNumber(mins)}`; +} + +function zeroPadNumber(num: number): string { + if (num < 10) { + return `0${num}`; + } + return String(num); +} + +export function getNow(): Date { + return new Date(Date.now()); +} + +export function getDateMinusHours(date: Date, hours: number): Date { + if (hours === 0) return date; + date.setHours(date.getHours() - hours); + return date; +} + +export function getUTCNowDateString(): string { + return getUTCDateString(getNow()); +} + +// Gets local time in format "M/DD/YYYY, HH:MM [AM|PM]" +export function getLongDateTimeString(d: Date): string { + const [date, time, period] = d.toLocaleString().split(" "); + const per = period ? ` ${period}` : ""; + return `${date} ${time.slice(0, -3)}${per}`; +} + +function getTime(date: Date): string { + const hour = zeroPadNumber(date.getHours()); + const mins = zeroPadNumber(date.getMinutes()); + return `${hour}:${mins}`; +} + +export function getTimeWithSeconds(date: Date): string { + const t = getTime(date); + return `${t}:${zeroPadNumber(date.getSeconds())}`; +} + +export function getTimeAgoString(oldDateTime: number): string { + return format(new Date(oldDateTime)); +} + +export function areTimeAgosEqual(a: Date, b: Date): boolean { + return format(a) === format(b); +} diff --git a/packages/utils/src/excerpt.ts b/packages/utils/src/excerpt.ts index 1b09253d..b6559e1a 100644 --- a/packages/utils/src/excerpt.ts +++ b/packages/utils/src/excerpt.ts @@ -1,4 +1,6 @@ -const truncate = (str: string, len: number) => `${str.slice(0, len - 1)}…`; +const truncate = (str: string, len: number): string => + `${str.slice(0, len - 1)}…`; -export default (str: string, len = 100) => - str.length < len ? str : truncate(str, len); +export default function excerpt(str: string, len = 100): string { + return str.length < len ? str : truncate(str, len); +} diff --git a/packages/utils/src/fakeEscapePress.ts b/packages/utils/src/fakeEscapePress.ts new file mode 100644 index 00000000..a980bfa3 --- /dev/null +++ b/packages/utils/src/fakeEscapePress.ts @@ -0,0 +1,24 @@ +/* +reactjs-popup stays in it's absolute position when scrolled, +this is a workaround until the issue is fixed +https://github.com/yjose/reactjs-popup/issues/208 + +Make sure to include the following in your react component + useEffectOnMount(() => { + document.addEventListener("wheel", fakeEscapePress); + return () => document.removeEventListener("wheel", fakeEscapePress); + }); +*/ + +export default function fakeEscapePress() { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + }), + ); + document.dispatchEvent( + new KeyboardEvent("keyup", { + key: "Escape", + }), + ); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5cdbd98b..2fce918e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,11 +1,29 @@ export { default as Maybe } from "./Maybe"; export { default as compareArray } from "./compareArray"; +export { + areTimeAgosEqual, + getDateMinusHours, + getDateString, + getLongDateTimeString, + getNow, + getTimeAgoString, + getTimeWithSeconds, + getUTCDateAndTimeString, + getUTCDateString, + getUTCNowDateString, + getUTCTimeString, + oneHour, +} from "./dateConversions"; export { default as dedupe } from "./dedupe"; export { default as enumKeys } from "./enumKeys"; export { default as excerpt } from "./excerpt"; +export { default as fakeEscapePress } from "./fakeEscapePress"; export { default as initialUppercase } from "./initialUppercase"; export { default as nTimes, nTimesWithIndex } from "./nTimes"; +export { default as pluralize } from "./pluralize"; export { default as prettyJSON, prettyJSONText } from "./prettyJSON"; export { default as randomArrayItem } from "./randomArrayItem"; +export { default as randomNum } from "./randomNum"; export { default as safeJSONParse } from "./safeJSONParse"; export { default as tuplify } from "./tuplify"; +export { Route } from "./urlUtils"; diff --git a/packages/utils/src/pluralize.ts b/packages/utils/src/pluralize.ts new file mode 100644 index 00000000..bbc37815 --- /dev/null +++ b/packages/utils/src/pluralize.ts @@ -0,0 +1,23 @@ +export default function pluralize(num: number, str: string): string { + if (num === 1) { + return str; + } + return getPlural(str); +} + +function getPlural(s: string): string { + switch (s) { + case "person": + return "people"; + case "repository": + return "repositories"; + case "repository updated today": + return "repositories updated today"; + case "database": + return "databases"; + case "database updated today": + return "databases updated today"; + default: + return `${s}s`; + } +} diff --git a/packages/utils/src/prettyJSON.ts b/packages/utils/src/prettyJSON.ts index 1372210b..c49f5b42 100644 --- a/packages/utils/src/prettyJSON.ts +++ b/packages/utils/src/prettyJSON.ts @@ -1,4 +1,4 @@ -export default function (data: any): string { +export default function prettyJSON(data: any): string { try { return JSON.stringify(data, null, 2); } catch (err) { diff --git a/packages/utils/src/randomNum.ts b/packages/utils/src/randomNum.ts new file mode 100644 index 00000000..28e8b55e --- /dev/null +++ b/packages/utils/src/randomNum.ts @@ -0,0 +1,3 @@ +export default function randomNum(min: number, max: number): number { + return Math.floor(Math.random() * (max - min) + min); +} diff --git a/packages/utils/src/urlUtils.ts b/packages/utils/src/urlUtils.ts new file mode 100644 index 00000000..43e8870d --- /dev/null +++ b/packages/utils/src/urlUtils.ts @@ -0,0 +1,270 @@ +import { ParsedUrlQueryInput } from "querystring"; +import { UrlObject } from "url"; + +type Url = string | UrlObject; + +export type LinkObject = { + href: Url; + as?: Url; +}; + +/** + * __Route__ + * + * `Route` class that handles adding static and dynamic route segments to + * existing routes. `Route` can be initialized with a "href" string or + * `UrlObject` and optional "as" string or `UrlObject`. + * + * @example + * const routeA = new Route("/repositories"); + * routeA.hrefPathname() // "/repositories" + * routeA.addDynamic("ownerName", "my-owner") + * routeA.hrefPathname() // "repositories/[ownerName]" + * routeA.asPathname() // "repositories/my-owner" + * + * const routeB = new Route( + * { pathname: "/repositories/[ownerName]", query: { refName: "main" } }, + * { pathname: "/repositories/my-owner", query: { refName: "main" } }, + * ) + * routeB.hrefPathname() // "/repositories/[ownerName]" + * routeB.asPathname() // "repositories/my-owner" + * routeB.getQuery() // { refName: "main" } + */ +export class Route { + href: Url; + + as: Url; + + constructor(href: Url, as?: Url) { + this.href = href; + this.as = as ?? href; + } + + /** + * __hrefPathname__ + * + * Gets pathname string from href string or `UrlObject`. + * + * @param addFrag Optional route fragment string to add to `href` pathname + * + * @returns `href` pathname string, with added route fragment if provided + * + * @example + * const routeA = new Route("/repositories"); + * routeA.hrefPathname() // "/repositories" + * routeA.hrefPathname("[ownerName]") // "repositories/[ownerName]" + * + * const routeB = new Route({ pathname: "/repositories" }) + * routeB.hrefPathname() // "/repositories" + * routeB.hrefPathname("[ownerName]") // "repositories/[ownerName]" + */ + hrefPathname(addFrag?: string): string { + const href = getPathname(this.href); + if (!addFrag) return href; + return `${href}/${addFrag}`; + } + + /** + * __asPathname__ + * + * Gets pathname string from as string or `UrlObject`. + * + * @param addFrag Optional route fragment string to add to `as` pathname + * + * @returns `as` pathname string, with added route fragment if provided + * + * @example + * const routeA = new Route("/repositories"); + * routeA.asPathname() // "/repositories" + * routeA.asPathname("my-username") // "repositories/my-username" + * + * const routeB = new Route( + * { pathname: "/repositories/[ownerName]" }, + * { pathname: "/repositories/my-username" }, + * ); + * routeB.asPathname() // "/repositories/my-username" + * routeB.asPathname("data") // "repositories/my-username/data" + */ + asPathname(addFrag?: string): string { + const as = getPathname(this.as); + if (!addFrag) return as; + return `${as}/${addFrag}`; + } + + /** + * __getQuery__ + * + * Gets query object from the href `UrlObject`. + * + * @returns query object + * + * @example + * const route = new Route("/repositories"); + * route.withQuery({ refName: "main" }) + * route.getQuery() // { refName: "main" } + */ + getQuery(): string | ParsedUrlQueryInput | undefined | null { + if (typeof this.href === "string") return undefined; + return this.href.query; + } + + /** + * __getHash__ + * + * Gets hash string from the href `UrlObject`. + * + * @returns hash string + * + * @example + * const route = new Route("/repositories"); + * route.withHash("commitA") + * route.getHash() // "commitA" + */ + getHash(): string { + if (typeof this.href === "string") return ""; + return this.href.hash ?? ""; + } + + /** + * __addStatic__ + * + * Adds static route fragment string to a `Route`. Static route fragments look like: + * ```ts + * { href: "/repositories", as: "/repositories" } + * ``` + * As opposed to dynamic route fragments which look like: + * ```ts + * { href: "/[ownerName]", as: "/owner-value" } + * ``` + * + * @param pathFrag Route fragment string to add to the url. "/" is automatically prepended. + * + * @returns new instance of `Route` + * + * @example + * const route = new Route("/repositories"); + * route.addStatic("data") + * route.hrefPathname() // "/repositories/data" + * route.asPathname() // "/repositories/data" + */ + addStatic(pathFrag: string): Route { + return new Route(this.hrefPathname(pathFrag), this.asPathname(pathFrag)); + } + + /** + * __maybeAddStatic__ + * + * Maybe adds static route fragment string to a `Route`. + * + * @see this.addStatic + * + * @param pathFrag Optional route fragment string to add to the url. "/" is automatically prepended. + * + * @example + * const route = new Route("/repositories"); + * route.maybeAddStatic("data") + * route.hrefPathname() // "/repositories/data" + * route.maybeAddStatic() + * route.hrefPathname() // "/repositories" + */ + maybeAddStatic(pathFrag?: string): Route { + return new Route(this.hrefPathname(pathFrag), this.asPathname(pathFrag)); + } + + /** + * __addDynamic__ + * + * Adds dynamic route fragment string to a Route. + * Dynamic route fragments look like: + * ```ts + * { href: "/[ownerName]", as: "/owner-value" } + * ``` + * As opposed to static route fragments which look like: + * + * ```ts + * { href: "/repositories", as: "/repositories" } + * ``` + * + * @param param String fragment to add to the href path. This value will be wrapped in square brackets. "/" is automatically prepended. + * @param as Value of the `param` to be added to the `as` path. "/" is automatically prepended. + * @param encode Default false. If true, encodes a `as` param value string as a valid component of a Uniform Resource Identifier (URI). + * + * @returns new instance of `Route` + * + * @example + * const route = new Route("/repositories"); + * route.addDynamic("ownerName", "my-owner-name") + * route.hrefPathname() // "/repositories/[ownerName]" + * route.asPathname() // "/repositories/my-owner-name" + */ + addDynamic(param: string, as: string, encode = false): Route { + const asStr = encode ? encodeURIComponent(as) : as; + return new Route(this.hrefPathname(`[${param}]`), this.asPathname(asStr)); + } + + /** + * __withQuery__ + * + * Adds query object to a `Route`. Looks like "?refName=main&active=Tables" + * when url is formatted. + * + * @param q Record with fields that will be added to the query string. + * + * @returns new instance of `Route` + * + * @example + * const route = new Route("/repositories/[ownerName]", "/repositories/my-owner"); + * route.withQuery({ refName: "main", active: undefined }) + * route.hrefPathname() // { pathname: "/repositories/[ownerName]", query: { refName: "main" } } + * route.asPathname() // { pathname: "/repositories/my-owner", query: { refName: "main" } } + */ + withQuery(q: Record): Route { + const query = queryHandler(q); + return new Route( + { pathname: this.hrefPathname(), query, hash: this.getHash() }, + { pathname: this.asPathname(), query, hash: this.getHash() }, + ); + } + + /** + * __withHash__ + * + * Adds hash string to a `Route`. Looks like "#hash-value" when url is formatted. + * + * @param hash Optional hash string + * + * @returns new instance of Route + * + * @example + * const route = new Route("/repositories/[ownerName]", "/repositories/my-owner"); + * route.withHash({ commitId: "commitA" }) + * route.hrefPathname() // { pathname: "/repositories/[ownerName]", hash: { commitId: "commitA" } } + * route.asPathname() // { pathname: "/repositories/my-owner", hash: { commitId: "commitA" } } + */ + withHash(hash?: string): Route { + return new Route( + { pathname: this.hrefPathname(), query: this.getQuery(), hash }, + { pathname: this.asPathname(), query: this.getQuery(), hash }, + ); + } +} + +// Gets pathname from url string or object +function getPathname(as?: string | UrlObject): string { + if (!as) return ""; + if (typeof as === "string") return as; + return as.pathname ?? ""; +} + +// Returns query object with undefined or empty values removed +export function queryHandler( + q: Record, +): string | null | ParsedUrlQueryInput | undefined { + return Object.keys(q).reduce((prev, key) => { + const currVal = q[key]; + if (currVal !== undefined && currVal !== null && currVal.length > 0) { + return { ...prev, [key]: currVal }; + } + return prev; + }, {} as NodeJS.Dict); +} diff --git a/yarn.lock b/yarn.lock index 452d0199..d0d63141 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1538,6 +1538,7 @@ __metadata: rollup-plugin-dts: "npm:^6.1.0" rollup-plugin-peer-deps-external: "npm:^2.2.4" rollup-plugin-terser: "npm:^7.0.2" + timeago.js: "npm:^4.0.2" typescript: "npm:^5.3.3" yalc: "npm:^1.0.0-pre.53" languageName: unknown @@ -7698,6 +7699,13 @@ __metadata: languageName: node linkType: hard +"timeago.js@npm:^4.0.2": + version: 4.0.2 + resolution: "timeago.js@npm:4.0.2" + checksum: e4cf66df2dfa2bc17db6e7a7c591d1e877e09e91e118784347a0b2390c38d7d292d2742e29edde9cdf4eaec61e8c4ef9b9f9c8b539f03d2613403dd7ccfc6cc8 + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" From 80a5abb778a2cfd7fb83e0d1f8a99003a5eaf411 Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 15:53:38 -0800 Subject: [PATCH 08/11] More utils --- packages/utils/package.json | 2 +- .../src/__tests__/dateConversions.test.ts | 1 + .../utils/src/__tests__/numberFormat.test.ts | 46 +++++++++++++++++++ packages/utils/src/index.ts | 8 ++++ packages/utils/src/null.ts | 15 ++++++ packages/utils/src/numberFormat.ts | 19 ++++++++ packages/utils/src/validators.ts | 39 ++++++++++++++++ 7 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 packages/utils/src/__tests__/numberFormat.test.ts create mode 100644 packages/utils/src/null.ts create mode 100644 packages/utils/src/numberFormat.ts create mode 100644 packages/utils/src/validators.ts diff --git a/packages/utils/package.json b/packages/utils/package.json index 2a99cfdf..644ef575 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -22,7 +22,7 @@ "lint": "eslint --cache --ext .ts,.js,.tsx,.jsx src", "prettier": "prettier --check 'src/**/*.{js,ts}'", "prettier-fix": "prettier --write 'src/**/*.{js,ts}'", - "publish": "yarn dbuild && npm publish", + "npm:publish": "yarn dbuild && npm publish", "test": "jest", "yalc:publish": "yarn dbuild && yalc publish", "yalc:push": "yarn dbuild && yalc push" diff --git a/packages/utils/src/__tests__/dateConversions.test.ts b/packages/utils/src/__tests__/dateConversions.test.ts index 019a3b55..cd02a110 100644 --- a/packages/utils/src/__tests__/dateConversions.test.ts +++ b/packages/utils/src/__tests__/dateConversions.test.ts @@ -17,6 +17,7 @@ // ]; describe("test date conversions", () => { + it("should pass", () => {}); // dateTests.forEach(test => { // it(`gets date and time string for ${test.date.toLocaleDateString()}`, () => { // expect(getDateAndTimeString(test.date)).toEqual(test.expectedDate); diff --git a/packages/utils/src/__tests__/numberFormat.test.ts b/packages/utils/src/__tests__/numberFormat.test.ts new file mode 100644 index 00000000..76d865f4 --- /dev/null +++ b/packages/utils/src/__tests__/numberFormat.test.ts @@ -0,0 +1,46 @@ +import { formatNumber, formatToRoundedUsd, formatToUsd } from "../numberFormat"; + +describe("formatNumber", () => { + it("formats whole numbers correctly", () => { + expect(formatNumber(0)).toEqual("0"); + expect(formatNumber(5)).toEqual("5"); + expect(formatNumber(600)).toEqual("600"); + expect(formatNumber(140045)).toEqual("140,045"); + }); + it("formats numbers with decimals", () => { + expect(formatNumber(0.0)).toEqual("0"); + expect(formatNumber(5.1)).toEqual("5.1"); + expect(formatNumber(600.009)).toEqual("600.009"); + expect(formatNumber(140045.994)).toEqual("140,045.994"); + }); +}); + +describe("formatToUsd", () => { + it("formats whole numbers correctly", () => { + expect(formatToUsd(0)).toEqual("$0.00"); + expect(formatToUsd(5)).toEqual("$5.00"); + expect(formatToUsd(600)).toEqual("$600.00"); + expect(formatToUsd(140045)).toEqual("$140,045.00"); + }); + it("formats numbers with decimals correctly", () => { + expect(formatToUsd(0.0)).toEqual("$0.00"); + expect(formatToUsd(5.1)).toEqual("$5.10"); + expect(formatToUsd(600.009)).toEqual("$600.01"); + expect(formatToUsd(140045.994)).toEqual("$140,045.99"); + }); +}); + +describe("formatToRoundedUsd", () => { + it("formats whole numbers correctly", () => { + expect(formatToRoundedUsd(0)).toEqual("$0"); + expect(formatToRoundedUsd(5)).toEqual("$5"); + expect(formatToRoundedUsd(600)).toEqual("$600"); + expect(formatToRoundedUsd(140045)).toEqual("$140,045"); + }); + it("formats numbers with decimals correctly", () => { + expect(formatToRoundedUsd(0.0)).toEqual("$0"); + expect(formatToRoundedUsd(5.1)).toEqual("$5"); + expect(formatToRoundedUsd(600.009)).toEqual("$600"); + expect(formatToRoundedUsd(140045.994)).toEqual("$140,046"); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2fce918e..524c4506 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -20,6 +20,13 @@ export { default as excerpt } from "./excerpt"; export { default as fakeEscapePress } from "./fakeEscapePress"; export { default as initialUppercase } from "./initialUppercase"; export { default as nTimes, nTimesWithIndex } from "./nTimes"; +export { + NULL_VALUE, + getDisplayValue, + getDisplayValueForApi, + isNullValue, +} from "./null"; +export { formatNumber, formatToRoundedUsd, formatToUsd } from "./numberFormat"; export { default as pluralize } from "./pluralize"; export { default as prettyJSON, prettyJSONText } from "./prettyJSON"; export { default as randomArrayItem } from "./randomArrayItem"; @@ -27,3 +34,4 @@ export { default as randomNum } from "./randomNum"; export { default as safeJSONParse } from "./safeJSONParse"; export { default as tuplify } from "./tuplify"; export { Route } from "./urlUtils"; +export { emailValidator, usernameValidator } from "./validators"; diff --git a/packages/utils/src/null.ts b/packages/utils/src/null.ts new file mode 100644 index 00000000..16ceb189 --- /dev/null +++ b/packages/utils/src/null.ts @@ -0,0 +1,15 @@ +// Using an unprintable string for null values so we can distinguish between +// string "null" and null +export const NULL_VALUE = "\uf5f2\ueb94NULL\uf5a8\ue6ff"; + +export function isNullValue(dv: string): boolean { + return dv === NULL_VALUE; +} + +export function getDisplayValue(dv: string): string { + return isNullValue(dv) ? "NULL" : dv; +} + +export function getDisplayValueForApi(dv: string): string | null { + return isNullValue(dv) ? null : dv; +} diff --git a/packages/utils/src/numberFormat.ts b/packages/utils/src/numberFormat.ts new file mode 100644 index 00000000..4349f8e7 --- /dev/null +++ b/packages/utils/src/numberFormat.ts @@ -0,0 +1,19 @@ +export function formatNumber(num: number): string { + return new Intl.NumberFormat("en-US").format(num); +} + +export function formatToUsd(num: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(num); +} + +export function formatToRoundedUsd(num: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(num); +} diff --git a/packages/utils/src/validators.ts b/packages/utils/src/validators.ts new file mode 100644 index 00000000..483d24f8 --- /dev/null +++ b/packages/utils/src/validators.ts @@ -0,0 +1,39 @@ +const emailRegex = /\S+@\S+\.\S+/; + +export function emailValidator(s: string) { + return { + isValid: !!s.match(emailRegex), + errorMsg: "Email format invalid", + }; +} + +const usernameRegex = /^[-a-z0-9_]{3,32}$/g; +const invalidRegex = /^-|^_|-$|_$|--|__|-_|_-/; + +export function usernameValidator(s: string) { + return { + isValid: !!s.match(usernameRegex) && !s.match(invalidRegex), + errorMsg: + "Username can only contain lowercase letters, dashes, and underscores and be between 3 and 32 characters", + detailedErrorMsg: getUsernameErrorMsg(s), + }; +} + +function getUsernameErrorMsg(s: string) { + if (s.length < 3) { + return "Username too short"; + } + if (s.length > 32) { + return "Username too long"; + } + if (s.toLowerCase() !== s) { + return "Username cannot have uppercase letters"; + } + if (!s.match(usernameRegex)) { + return "Username invalid"; + } + if (s.match(invalidRegex)) { + return "Username invalid"; + } + return ""; +} From d38cdd2b8ace4d6adae1f51ccdb02b3f89a09b9d Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 16:25:25 -0800 Subject: [PATCH 09/11] Parse sql query --- packages/utils/jest.config.js | 8 +- packages/utils/package.json | 1 + .../src/__tests__/helpers/mutationExamples.ts | 78 +++ .../utils/src/__tests__/parseSqlQuery.test.ts | 449 ++++++++++++++++++ packages/utils/src/dateConversions.ts | 16 + packages/utils/src/index.ts | 16 + packages/utils/src/parseSqlQuery.ts | 379 +++++++++++++++ yarn.lock | 17 + 8 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 packages/utils/src/__tests__/helpers/mutationExamples.ts create mode 100644 packages/utils/src/__tests__/parseSqlQuery.test.ts create mode 100644 packages/utils/src/parseSqlQuery.ts diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js index e61a4089..14672e62 100644 --- a/packages/utils/jest.config.js +++ b/packages/utils/jest.config.js @@ -5,7 +5,13 @@ module.exports = { transform: { "^.+\\.tsx?$": "babel-jest", }, - testPathIgnorePatterns: ["types", "node_modules", ".rollup.cache", "dist"], + testPathIgnorePatterns: [ + "types", + "node_modules", + ".rollup.cache", + "dist", + "helpers", + ], moduleFileExtensions: ["ts", "js"], collectCoverage: false, clearMocks: true, diff --git a/packages/utils/package.json b/packages/utils/package.json index 644ef575..6090d508 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -58,6 +58,7 @@ "url": "https://github.com/dolthub/react-library/issues" }, "dependencies": { + "node-sql-parser": "^4.17.0", "timeago.js": "^4.0.2" } } diff --git a/packages/utils/src/__tests__/helpers/mutationExamples.ts b/packages/utils/src/__tests__/helpers/mutationExamples.ts new file mode 100644 index 00000000..276747a1 --- /dev/null +++ b/packages/utils/src/__tests__/helpers/mutationExamples.ts @@ -0,0 +1,78 @@ +export const mutationExamples = [ + "INSERT INTO tablename (id, name) VALUES (1, 'taylor')", + "INSERT INTO tablename VALUES (1, 'taylor')", + "INSERT IGNORE INTO tablename (id, name) VALUES (1, 'blah')", + "INSERT IGNORE INTO tablename VALUES (1, 'blah')", + "UPDATE tablename SET name='Taylor' WHERE id=1", + "DROP TABLE tablename", + "CREATE TABLE tablename (id INT, name VARCHAR(255), PRIMARY KEY(id))", + "CREATE TABLE tablename (id INT, name BIT(1), PRIMARY KEY(id))", + "ALTER TABLE tablename ADD newcolumn INT", + "ALTER TABLE newtable ADD newercolumn BIT(1)", + "ALTER TABLE newtable DROP PRIMARY KEY", + "ALTER TABLE newtable DROP COLUMN id", + "ALTER TABLE emp CHANGE salary salary2 VARCHAR(45)", + "alter table players rename column id to player_id", + "CREATE TABLE dev_company ( company_id INT, company_name VARCHAR(255), is_buildable BOOLEAN, PRIMARY KEY(company_id) )", + "CREATE INDEX id_index ON emp(ID);", + "CREATE TABLE emp2 AS SELECT * FROM emp", + "CREATE TABLE emp2 SELECT * FROM emp", + "CREATE VIEW test AS SELECT * FROM `tablename` LIMIT 200;", + `with oops as ( + SELECT from_name,to_ccn, to_name + from dolt_commit_diff_hospitals where from_commit = 'qtd6vb07pq7bfgt67m863anntm6fpu7n' + and to_commit = 'p730obnbmihnlq54uvenck13h12f7831' + and from_name <> to_name + ) + update hospitals h + join oops o + on h.ccn = o.to_ccn + and h.name <> o.from_name + set h.name = o.from_name`, + "DROP VIEW view_name", + "DROP SCHEMA database_name", + "drop view `view_name`", + `CREATE TRIGGER trigger1 + BEFORE UPDATE ON t + FOR EACH ROW + SET NEW.t1 = CURRENT_TIMESTAMP()`, + "drop trigger trigger1", + "CREATE DATABASE database_name", + "DROP DATABASE database_name", + "CREATE TABLE table_name (column1 varchar(255), column2 varchar(255), column3 varchar(255))", + "ALTER TABLE table_name ADD column4 varchar(255)", + "ALTER TABLE table_name MODIFY column1 varchar(255)", + "ALTER TABLE table_name DROP column1", + "DROP TABLE table_name", + "TRUNCATE TABLE table_name", + "INSERT INTO table_name (column1, column2) VALUES ('value1', 'value2')", + "INSERT INTO table_name (column1, column2) SELECT column1, column2 FROM table2 WHERE column1 = 'value'", + "UPDATE table_name SET column1 = 'new_value' WHERE column2 = 'value'", + "UPDATE table_name SET column1 = column1 + 10 WHERE column2 = 'value'", + "DELETE FROM table_name WHERE column1 = 'value'", + "DELETE FROM table_name WHERE column1 IN ('value1', 'value2')", + "DELETE FROM table_name WHERE column1 LIKE '%value%'", + "CREATE INDEX index_name ON table_name (column1)", + "DROP INDEX index_name ON table_name", + "CREATE UNIQUE INDEX index_name ON table_name (column1)", + "CREATE FULLTEXT INDEX index_name ON table_name (column1)", + "CREATE VIEW view_name AS SELECT column1, column2 FROM table_name WHERE column1 = 'value'", + "DROP VIEW view_name", + "CREATE PROCEDURE procedure_name (IN param1 varchar(255), OUT param2 varchar(255)) BEGIN SELECT column1, column2 INTO param2, param3 FROM table_name WHERE column1 = param1; END;", + "DROP PROCEDURE procedure_name", + "CREATE FUNCTION function_name (param1 varchar(255)) RETURNS varchar(255) BEGIN DECLARE var1 varchar(255); SELECT column1 INTO var1 FROM table_name WHERE column2 = param1; RETURN var1; END;", + "DROP FUNCTION function_name", + "CREATE TRIGGER trigger_name BEFORE INSERT ON table_name FOR EACH ROW BEGIN INSERT INTO log_table (column1, column2) VALUES (NEW.column1, NEW.column2); END;", + "DROP TRIGGER trigger_name", + "SET @@GLOBAL.sql_mode = 'mode1, mode2'", + "SET @@SESSION.sql_mode = 'mode1, mode2'", + "SET PASSWORD = 'password'", + "GRANT SELECT, INSERT ON table_name TO user_name", + "REVOKE SELECT, INSERT ON table_name FROM user_name", + "CREATE USER user_name IDENTIFIED BY 'password'", + "DROP USER user_name", + "ALTER USER user_name IDENTIFIED BY 'new_password'", + "SET GLOBAL slow_query_log = 1", + "SET SESSION slow_query_log = 1", + "FLUSH PRIVILEGES", +]; diff --git a/packages/utils/src/__tests__/parseSqlQuery.test.ts b/packages/utils/src/__tests__/parseSqlQuery.test.ts new file mode 100644 index 00000000..ac594a53 --- /dev/null +++ b/packages/utils/src/__tests__/parseSqlQuery.test.ts @@ -0,0 +1,449 @@ +import compareArray from "../compareArray"; +import { NULL_VALUE } from "../null"; +import { + convertToSqlWithNewCondition, + convertToSqlWithOrderBy, + fallbackGetTableNamesForSelect, + getQueryType, + getTableName, + isMultipleQueries, + isMutation, + makeQueryExecutable, + removeColumnFromQuery, + TableColumn, +} from "../parseSqlQuery"; +import { mutationExamples } from "./helpers/mutationExamples"; + +const invalidQuery = `this is not a valid query`; + +describe("parse sql query", () => { + it("check if the string contains multiple queries", () => { + const twoQueries = `SELECT * FROM test; SELECT * FROM test2;`; + expect(isMultipleQueries(twoQueries)).toBe(true); + const singleQueries = `SELECT * FROM test`; + expect(isMultipleQueries(singleQueries)).toBe(false); + const queryWithSemicolon = `INSERT INTO test (pk, col1) VALUES(1, 'this has semicolon; should be false')`; + expect(isMultipleQueries(queryWithSemicolon)).toBe(false); + expect(isMultipleQueries(invalidQuery)).toBe(false); + }); + + it("gets the table name from a select query string for lunch-places", () => { + const lpTableName = "lunch-places"; + const basicQuery = `SELECT * FROM \`${lpTableName}\``; + expect(getTableName(basicQuery)).toBe(lpTableName); + + const queryWithCols = `SELECT name, \`type of food\`, rating FROM \`${lpTableName}\``; + expect(getTableName(queryWithCols)).toBe(lpTableName); + + const queryWithWhereClause = `${queryWithCols} WHERE name = "Sidecar"`; + expect(getTableName(queryWithWhereClause)).toBe(lpTableName); + + const queryWithNewLines = `SELECT *\nFROM \`${lpTableName}\`\nORDER BY rating DESC`; + expect(getTableName(queryWithNewLines)).toBe(lpTableName); + + const queryWithColsAndWhereNotClause = `SELECT \`name\`, \`restaurant_name\`, \`identifier\`, \`fat_g\` FROM \`menu-items\` WHERE NOT (\`name\` = "APPLE SLICES" AND \`restaurant_name\` = "MCDONALD'S" AND \`identifier\` = "NATIONAL")`; + expect(getTableName(queryWithColsAndWhereNotClause)).toBe("menu-items"); + + expect(() => getTableName(invalidQuery)).not.toThrowError(); + }); + + it("gets the table name for mutations", () => { + expect(getTableName("DROP TABLE `test`")).toBe("test"); + expect( + getTableName("INSERT INTO test (pk, col1) VALUES (1, 'string')"), + ).toBe("test"); + expect(getTableName("CREATE TABLE `test table` (pk int primary key)")).toBe( + "test table", + ); + expect(getTableName("DELETE FROM test WHERE id=1")).toBe("test"); + expect(getTableName("ALTER TABLE `testing` DROP COLUMN `c1`")).toBe( + "testing", + ); + expect( + getTableName("UPDATE `test` SET `pk` = '10' WHERE `pk` = '2'"), + ).toEqual("test"); + }); + + it("gets the table name from a select query string for dolt_commit_diff table", () => { + const ddTableName = "dolt_commit_diff_career_totals_allstar"; + const basicQuery = `SELECT * FROM ${ddTableName}`; + expect(getTableName(basicQuery)).toBe(ddTableName); + + const queryWithCols = `SELECT from_league_id, to_league_id FROM ${ddTableName}`; + expect(getTableName(queryWithCols)).toBe(ddTableName); + + const queryWithWhereClause = `${queryWithCols} WHERE league_id = "00"`; + expect(getTableName(queryWithWhereClause)).toBe(ddTableName); + + const queryWithNewLines = `SELECT *\nFROM ${ddTableName}\nORDER BY fg_pct DESC`; + expect(getTableName(queryWithNewLines)).toBe(ddTableName); + + const queryWithNewLinesAndBackticks = `SELECT \`from_col1\`, \`to_col1\`, from_commit, from_commit_date, to_commit, to_commit_date, diff_type + FROM \`dolt_commit_diff_foo\` + WHERE (\`to_pk\` = "3" OR \`from_pk\` = "3") AND (\`from_col1\` <> \`to_col1\` OR (\`from_col1\` IS NULL AND \`to_col1\` IS NOT NULL) OR (\`from_col1\` IS NOT NULL AND \`to_col1\` IS NULL)) + ORDER BY to_commit_date DESC`; + expect(getTableName(queryWithNewLinesAndBackticks)).toBe( + "dolt_commit_diff_foo", + ); + }); + + it("adds a new condition to a query string for null and non-null values", () => { + const column = "rating"; + const value = "10"; + const nullVal = NULL_VALUE; + const condition = `\`${column}\` = '${value}'`; + const conditionNullVal = `\`${column}\` IS NULL`; + + const query = "SELECT * FROM `lunch-places`"; + const expectedNoConditions = `${query} WHERE ${condition}`; + expect(convertToSqlWithNewCondition(query, column, value)).toBe( + expectedNoConditions, + ); + + const expectedNoConditionsNullVal = `${query} WHERE ${conditionNullVal}`; + expect(convertToSqlWithNewCondition(query, column, nullVal)).toBe( + expectedNoConditionsNullVal, + ); + + const queryWithCondition = `${query} WHERE \`type of food\` = 'mexican'`; + const expectedWithCondition = `${queryWithCondition} AND ${condition}`; + expect( + convertToSqlWithNewCondition(queryWithCondition, column, value), + ).toBe(expectedWithCondition); + + const expectedWithConditionNullVal = `${queryWithCondition} AND ${conditionNullVal}`; + expect( + convertToSqlWithNewCondition(queryWithCondition, column, nullVal), + ).toBe(expectedWithConditionNullVal); + + const queryWithConditions = `${query} WHERE (\`type of food\` = 'mexican' OR \`best dish\` = 'burrito')`; + const expectedWithConditions = `${queryWithConditions} AND ${condition}`; + expect( + convertToSqlWithNewCondition(queryWithConditions, column, value), + ).toBe(expectedWithConditions); + + const expectedWithConditionsNullVal = `${queryWithConditions} AND ${conditionNullVal}`; + expect( + convertToSqlWithNewCondition(queryWithConditions, column, nullVal), + ).toBe(expectedWithConditionsNullVal); + + const queryWithConditionAndLimit = `${query} WHERE \`type of food\` = 'mexican' LIMIT 100`; + const expectedWithConditionAndLimit = `${query} WHERE \`type of food\` = 'mexican' AND ${condition} LIMIT 100`; + expect( + convertToSqlWithNewCondition(queryWithConditionAndLimit, column, value), + ).toBe(expectedWithConditionAndLimit); + + const expectedWithConditionAndLimitNullVal = `${query} WHERE \`type of food\` = 'mexican' AND ${conditionNullVal} LIMIT 100`; + expect( + convertToSqlWithNewCondition(queryWithConditionAndLimit, column, nullVal), + ).toBe(expectedWithConditionAndLimitNullVal); + + expect(() => + convertToSqlWithNewCondition(invalidQuery, column, value), + ).not.toThrowError(); + }); + + it("adds or removes order by clause to query", () => { + const column = "name"; + const type = "ASC"; + const query = "SELECT * FROM `lunch-places`"; + const expectedNoOrderBy = `${query} ORDER BY \`${column}\` ${type}`; + expect(convertToSqlWithOrderBy(query, column, type)).toBe( + expectedNoOrderBy, + ); + expect(convertToSqlWithOrderBy(expectedNoOrderBy, column)).toBe(query); + + const queryOrderBy = `${query} ORDER BY \`rating\` ASC`; + const expectedOrderBy = `${queryOrderBy}, \`${column}\` ${type}`; + expect(convertToSqlWithOrderBy(queryOrderBy, column, type)).toBe( + expectedOrderBy, + ); + expect(convertToSqlWithOrderBy(expectedOrderBy, column)).toBe(queryOrderBy); + + const queryOrderBySameCol = `${query} ORDER BY \`${column}\` DESC`; + const expectedOrderBySameCol = `${query} ORDER BY \`${column}\` ${type}`; + expect(convertToSqlWithOrderBy(queryOrderBySameCol, column, type)).toBe( + expectedOrderBySameCol, + ); + expect(convertToSqlWithOrderBy(expectedOrderBySameCol, column)).toBe(query); + + const queryOrderBySameColSameOrder = `${query} ORDER BY \`${column}\` DESC, \`rating\` ASC LIMIT 20`; + const expectedOrderBySameColSameOrder = `${query} ORDER BY \`${column}\` ${type}, \`rating\` ASC LIMIT 20`; + const expectedQueryRemoved = `${query} ORDER BY \`rating\` ASC LIMIT 20`; + expect( + convertToSqlWithOrderBy(queryOrderBySameColSameOrder, column, type), + ).toBe(expectedOrderBySameColSameOrder); + expect( + convertToSqlWithOrderBy(expectedOrderBySameColSameOrder, column), + ).toBe(expectedQueryRemoved); + + expect(() => + convertToSqlWithOrderBy(invalidQuery, column), + ).not.toThrowError(); + }); + + it("gets query type", () => { + expect(getQueryType("SELECT * FROM tablename")).toEqual("select"); + expect(getQueryType("SHOW TABLES")).toEqual("show"); + expect( + getQueryType("INSERT INTO tablename (id, name) values (1, 'taylor')"), + ).toEqual("insert"); + expect( + getQueryType("UPDATE tablename SET name='Taylor' WHERE id=1"), + ).toEqual("update"); + expect(getQueryType("DELETE FROM tablename WHERE id=1")).toEqual("delete"); + expect(getQueryType("DROP TABLE tablename")).toEqual("drop"); + expect( + getQueryType( + "CREATE TABLE tablename (id INT, name VARCHAR(255), PRIMARY KEY(id))", + ), + ).toEqual("create"); + expect(() => getQueryType(invalidQuery)).not.toThrowError(); + expect(getQueryType(invalidQuery)).toEqual(undefined); + }); +}); + +describe("test isMutation", () => { + const notMutations = [ + "SELECT * FROM tablename", + "SHOW TABLES", + "INVALID QUERY", + "DESCRIBE tablename", + "SHOW CREATE TABLE tablename", + "SHOW CREATE VIEW `view_name`", + "Select * from TABLE_NAME group by dept having salary > 10000;", + `with oops as ( + SELECT from_name,to_ccn, to_name + from dolt_commit_diff_hospitals where from_commit = 'qtd6vb07pq7bfgt67m863anntm6fpu7n' + and to_commit = 'p730obnbmihnlq54uvenck13h12f7831' + and from_name <> to_name + ) + select h.*, o.* hospitals h + join oops o + on h.ccn = o.to_ccn + and h.name <> o.from_name + set h.name = o.from_name +`, + "SHOW DATABASES", + "SHOW COLUMNS FROM table_name", + "SHOW INDEXES FROM table_name", + "SHOW CREATE TABLE table_name", + "SHOW TRIGGERS", + "SHOW PROCEDURE STATUS", + "SHOW FUNCTION STATUS", + "SHOW GRANTS FOR user_name", + "SHOW PROCESSLIST", + "SHOW STATUS", + ]; + notMutations.forEach(q => { + it(`isMutation is false for "${q}"`, () => { + expect(isMutation(q)).toBeFalsy(); + }); + }); + + mutationExamples.forEach(q => { + it(`isMutation is true for "${q}"`, () => { + expect(isMutation(q)).toBeTruthy(); + }); + }); + + it("doesn't throw error for invalid query", () => { + expect(() => isMutation(invalidQuery)).not.toThrowError(); + }); +}); + +const columns = [ + { + name: "id", + constraintsList: [], + isPrimaryKey: true, + type: "VARCHAR(16383)", + sourceTable: "tablename", + }, + { + name: "name", + constraintsList: [], + isPrimaryKey: true, + type: "VARCHAR(16383)", + sourceTable: "tablename", + }, + { + name: "age", + constraintsList: [], + isPrimaryKey: true, + type: "VARCHAR(16383)", + sourceTable: "tablename", + }, +]; + +const joinedColumns = [ + { + name: "id", + constraintsList: [], + isPrimaryKey: true, + type: "VARCHAR(16383)", + sourceTable: "tablename", + }, + { + name: "name", + constraintsList: [], + isPrimaryKey: true, + type: "VARCHAR(16383)", + sourceTable: "tablename", + }, + { + name: "age", + constraintsList: [], + isPrimaryKey: true, + type: "VARCHAR(16383)", + sourceTable: "tablename", + }, + { + name: "id", + constraintsList: [], + isPrimaryKey: true, + type: "VARCHAR(16383)", + sourceTable: "tablename2", + }, +]; + +describe("removes column from query", () => { + const tests: Array<{ + desc: string; + query: string; + colToRemove: string; + cols: TableColumn[]; + expected: string; + }> = [ + { + desc: "select query", + query: "SELECT * FROM tablename", + colToRemove: "name", + cols: columns.slice(0, 2), + expected: "SELECT `id` FROM `tablename`", + }, + { + desc: "select query with where clause", + query: "SELECT id, name, age FROM tablename WHERE id=1", + colToRemove: "id", + cols: columns, + expected: "SELECT `name`, `age` FROM `tablename` WHERE `id` = 1", + }, + { + desc: "select query with where not clause with double quoted single quote", + query: `SELECT id, name, age FROM tablename WHERE NOT (id=1 AND name = "MCDONALD'S")`, + colToRemove: "name", + cols: columns, + expected: `SELECT \`id\`, \`age\` FROM \`tablename\` WHERE NOT(\`id\` = 1 AND \`name\` = "MCDONALD\\'S")`, + }, + { + desc: "select query with where clause with escaped single quote", + query: `SELECT * FROM tablename WHERE name = 'MCDONALD\\'S'`, + colToRemove: "age", + cols: columns, + expected: `SELECT \`id\`, \`name\` FROM \`tablename\` WHERE \`name\` = 'MCDONALD\\'S'`, + }, + { + desc: "select query with where clause with two escaped single quotes", + query: `SELECT * FROM tablename WHERE name = 'MCDONALD\\'S' OR name = 'Jinky\\'s Cafe'`, + colToRemove: "age", + cols: columns, + expected: `SELECT \`id\`, \`name\` FROM \`tablename\` WHERE \`name\` = 'MCDONALD\\'S' OR \`name\` = 'Jinky\\'s Cafe'`, + }, + { + desc: "select query with join clause", + query: + "SELECT * FROM tablename, tablename2 where tablename.id = tablename2.id", + colToRemove: "name", + cols: joinedColumns, + expected: + "SELECT `tablename`.`id`, `tablename`.`age`, `tablename2`.`id` FROM `tablename`, `tablename2` WHERE `tablename`.`id` = `tablename2`.`id`", + }, + ]; + + tests.forEach(test => { + it(test.desc, () => { + expect( + removeColumnFromQuery(test.query, test.colToRemove, test.cols), + ).toEqual(test.expected); + }); + }); + + expect(() => + removeColumnFromQuery(invalidQuery, "age", columns.slice(0, 2)), + ).not.toThrowError(); +}); + +describe("test executable query", () => { + const tests = [ + { + desc: "escapes single quotes", + query: "select * from tablename where col='name'", + }, + { + desc: "removes extra whitespace", + query: ` select * +from tablename +where col='name' + + `, + }, + ]; + tests.forEach(test => { + it(test.desc, () => { + expect(makeQueryExecutable(test.query)).toEqual( + "select * from tablename where col=\\'name\\'", + ); + }); + }); +}); + +describe("test use regex to get table names from query", () => { + const tests = [ + { + desc: "single table", + query: "select * from tablename where col='name'", + expected: ["tablename"], + }, + { + desc: "single table with where clause", + query: "select * from tablename where col='name'", + expected: ["tablename"], + }, + { + desc: "multiple tables using , to join", + query: "select * from table1, table2 where table1.id = table2.id", + expected: ["table1", "table2"], + }, + { + desc: "multiple tables using join clause", + query: "select * from table1 join table2 on table1.id = table2.id", + expected: ["table1", "table2"], + }, + { + desc: "multiple tables with table names in backticks", + query: + "select * from `table1` join `table2` on `table1`.id = `table2`.id", + expected: ["table1", "table2"], + }, + { + desc: "multiple tables with column name includes from", + query: + "select * from table1 join table2 on table1.from_commit = table2.from_commit", + expected: ["table1", "table2"], + }, + // { + // desc: "more than 2 tables", + // query: + // "select * from table1, table2, table3 where table1.id = table2.id and table2.id = table3.id", + // expected: ["table1", "table2", "table3"], + // }, + ]; + tests.forEach(test => { + it(test.desc, () => { + expect( + compareArray(fallbackGetTableNamesForSelect(test.query), test.expected), + ).toBe(true); + }); + }); +}); diff --git a/packages/utils/src/dateConversions.ts b/packages/utils/src/dateConversions.ts index e13a9aba..f7613067 100644 --- a/packages/utils/src/dateConversions.ts +++ b/packages/utils/src/dateConversions.ts @@ -5,12 +5,14 @@ const oneSecond = 1000; const oneMinute = oneSecond * 60; export const oneHour = oneMinute * 60; +// Gets UTC date in format YYYY-MM-DD HH:MM:SS export function getUTCDateAndTimeString(date: Date): string { const d = getUTCDateString(date); const t = `${getUTCTimeString(date)}:${zeroPadNumber(date.getUTCSeconds())}`; return `${d} ${t}`; } +// Gets date in format YYYY-MM-DD export function getDateString(date: Date): string { const month = zeroPadNumber(date.getMonth() + 1); // January is 0 const day = zeroPadNumber(date.getDate()); @@ -18,6 +20,7 @@ export function getDateString(date: Date): string { return `${year}-${month}-${day}`; } +// Gets UTC date in format YYYY-MM-DD export function getUTCDateString(date: Date): string { const month = zeroPadNumber(date.getUTCMonth() + 1); // January is 0 const day = zeroPadNumber(date.getUTCDate()); @@ -25,6 +28,7 @@ export function getUTCDateString(date: Date): string { return `${year}-${month}-${day}`; } +// Gets UTC time in format HH:MM:SS export function getUTCTimeString(date: Date): string { const hour = date.getUTCHours(); const mins = date.getUTCMinutes(); @@ -77,3 +81,15 @@ export function getTimeAgoString(oldDateTime: number): string { export function areTimeAgosEqual(a: Date, b: Date): boolean { return format(a) === format(b); } + +// Manually convert local date to UTC +export function convertToUTCDate(d: Date): Date { + const utcDate = new Date(d); + utcDate.setUTCFullYear(d.getFullYear()); + utcDate.setUTCMonth(d.getMonth()); + utcDate.setUTCDate(d.getDate()); + utcDate.setUTCHours(d.getHours()); + utcDate.setUTCMinutes(d.getMinutes()); + utcDate.setUTCSeconds(d.getSeconds()); + return utcDate; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 524c4506..d1930109 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,6 +2,7 @@ export { default as Maybe } from "./Maybe"; export { default as compareArray } from "./compareArray"; export { areTimeAgosEqual, + convertToUTCDate, getDateMinusHours, getDateString, getLongDateTimeString, @@ -27,6 +28,21 @@ export { isNullValue, } from "./null"; export { formatNumber, formatToRoundedUsd, formatToUsd } from "./numberFormat"; +export { + convertToSqlWithNewColNames, + convertToSqlWithNewCols, + convertToSqlWithNewCondition, + convertToSqlWithOrderBy, + getColumns, + getTableName, + isMultipleQueries, + isMutation, + makeQueryExecutable, + parseSelectQuery, + queryHasOrderBy, + removeColumnFromQuery, + requireTableNameForSelect, +} from "./parseSqlQuery"; export { default as pluralize } from "./pluralize"; export { default as prettyJSON, prettyJSONText } from "./prettyJSON"; export { default as randomArrayItem } from "./randomArrayItem"; diff --git a/packages/utils/src/parseSqlQuery.ts b/packages/utils/src/parseSqlQuery.ts new file mode 100644 index 00000000..56c748a2 --- /dev/null +++ b/packages/utils/src/parseSqlQuery.ts @@ -0,0 +1,379 @@ +import { AST, ColumnRef, Expr, OrderBy, Parser, Select } from "node-sql-parser"; +import Maybe from "./Maybe"; +import { isNullValue } from "./null"; + +export type TableColumn = { + name: string; + sourceTable?: Maybe; +}; + +const parser = new Parser(); +const opt = { + database: "MYSQL", +}; + +function getQueryError(q: string, err: unknown): string { + return `query: ${q}\nerror: ${err}`; +} + +export function parseSelectQuery(q: string): Select | null { + let ast = {}; + try { + ast = parser.astify(q, opt); + } catch (err) { + console.error(getQueryError(q, err)); + } + const obj: AST = Array.isArray(ast) ? ast[0] : ast; + return obj.type === "select" ? obj : null; +} + +// Gets table names as array `{type}::{dbName}::{tableName}` and converts to array of tableNames +export function getTableNames(q: string): string[] | undefined { + try { + return parser.tableList(q, opt).map(tn => tn.split("::")[2]); + } catch (err) { + console.error(getQueryError(q, err)); + return undefined; + } +} + +export function requireTableNameForSelect(q: string): string[] { + return getTableNames(q) ?? fallbackGetTableNamesForSelect(q); +} + +// Extracts tableName from query +export function getTableName(q?: string): Maybe { + if (!q) return undefined; + const tns = getTableNames(q); + if (!tns || tns.length === 0) return undefined; + return tns[0]; +} + +// Uses regex to match table names in query "SELECT [columns] FROM [tableName] ..." +// does not work on more than 2 tables. but better than just extract 1 table +export function fallbackGetTableNamesForSelect(query: string): string[] { + const tableNameRegex = + /\b(?:from|join)\s+`?(\w+)`?(?:\s*(?:join|,)\s+`?(\w+)`?)*\b/gi; + const matches = [...query.matchAll(tableNameRegex)]; + const tableNames = matches.flatMap(match => match.slice(1).filter(Boolean)); + return tableNames; +} + +type Column = { + expr: ColumnRef; + as: string | null; +}; + +type Columns = any[] | "*" | Column[]; + +// Extracts columns from query string +export function getColumns(q: string): Columns | undefined { + const ast = parseSelectQuery(q); + return ast?.columns; +} + +function getSqlColumn(name: string): Column { + return { + expr: { + type: "column_ref", + table: null, + column: name, + }, + as: null, + }; +} + +function mapColsToColumnNames(cols: string[] | "*"): Column[] { + if (cols === "*") { + return [getSqlColumn("*")]; + } + return cols.map(c => getSqlColumn(c)); +} + +function mapColsToColumnRef( + cols: TableColumn[] | "*", + isJoinClause: boolean, +): Column[] { + if (cols === "*") { + return [getSqlColumn("*")]; + } + return cols.map(c => { + return { + expr: { + type: "column_ref", + table: isJoinClause && c.sourceTable ? c.sourceTable : null, + column: c.name, + }, + as: null, + }; + }); +} + +export function convertToSql(select: Select): string { + return parser.sqlify(select, opt); +} + +// Converts query string to sql with new table name and columns +export function convertToSqlWithNewColNames( + q: string, + cols: string[] | "*", + tableName: string, +): string { + const ast = parseSelectQuery(q); + const columns = mapColsToColumnNames(cols); + if (!ast) return ""; + const newAst: Select = { + ...ast, + columns, + from: [{ db: null, table: tableName, as: null }], + where: escapeSingleQuotesInWhereObj(ast.where), + }; + return convertToSql(newAst); +} + +// Converts query string to sql with new table name and columns +export function convertToSqlWithNewCols( + q: string, + cols: TableColumn[] | "*", + tableNames?: string[], +): string { + const ast = parseSelectQuery(q); + const isJoinClause = tableNames && tableNames.length > 1; + const columns = mapColsToColumnRef(cols, !!isJoinClause); + + if (!ast) return ""; + if (!tableNames || tableNames.length === 0) { + return convertToSql({ + ...ast, + columns, + from: [{ db: null, table: null, as: null }], + where: escapeSingleQuotesInWhereObj(ast.where), + }); + } + const newAst: Select = { + ...ast, + columns, + from: tableNames.map(table => { + return { db: null, table, as: null }; + }), + where: escapeSingleQuotesInWhereObj(ast.where), + }; + return convertToSql(newAst); +} + +// Adds condition to query string +export function convertToSqlWithNewCondition( + query: string, + column: string, + value: string, +): string { + const parsed = parseSelectQuery(query); + if (!parsed) { + return query; + } + const where = getWhereObj(column, value, parsed); + return convertToSql({ + ...parsed, + where, + }); +} + +// Adds order by clause to query string +export function convertToSqlWithOrderBy( + query: string, + column: string, + type?: "ASC" | "DESC", +): string { + const parsed = parseSelectQuery(query); + if (!parsed) { + return query; + } + const orderby = getOrderByArr(parsed, column, type); + return convertToSql({ ...parsed, orderby }); +} + +// Check if query has order by clause for column and type +export function queryHasOrderBy( + query: string, + column: string, + type?: "ASC" | "DESC", +): boolean { + const parsed = parseSelectQuery(query); + if (!parsed) { + return false; + } + // If no order by, return true for default and false otherwise + if (!parsed.orderby) { + return !type; + } + // If default, check if order by for column exists + if (!type) { + return !parsed.orderby.some(o => o.expr.column === column); + } + // Check if column and type match + return parsed.orderby.some(o => o.expr.column === column && o.type === type); +} + +// Creates where object from conditions or adds conditions to existing where object +function getWhereObj(column: string, value: string, parsed: Select) { + const valIsNull = isNullValue(value); + const escapedVal = escapeSingleQuotes(value); + const newCondition: Expr = { + type: "binary_expr", + operator: valIsNull ? "IS" : "=", + left: { + type: "column_ref", + table: null, + column, + }, + right: { + type: valIsNull ? "null" : "string", + value: valIsNull ? null : escapedVal, + }, + }; + + if (!parsed.where) { + return newCondition; + } + const condition: Expr = { + type: "binary_expr", + operator: "AND", + left: { ...escapeSingleQuotesInWhereObj(parsed.where) }, + right: newCondition, + }; + return condition; +} + +// The where object is a binary tree with 'left' and 'right' nodes +function escapeSingleQuotesInWhereObj(where: any): any { + if (!where) return null; + + if (where.args) { + escapeSingleQuotesInWhereObj(where.args); + } + + if (where.expr) { + escapeSingleQuotesInWhereObj(where.expr); + } + + if (where.left) { + escapeSingleQuotesInWhereObj(where.left); + } + + if (where.value) { + if (typeof where.value === "string") { + // eslint-disable-next-line no-param-reassign + where.value = escapeSingleQuotes(where.value); + } + if (Array.isArray(where.value)) { + where.value.forEach((val: any) => escapeSingleQuotesInWhereObj(val)); + } + } + + if (where.right) { + escapeSingleQuotesInWhereObj(where.right); + } + + return where; +} + +function getOrderByArr( + parsed: Select, + column: string, + type?: "ASC" | "DESC", +): OrderBy[] | null { + // If default, remove order by clause for column + if (!type) { + return parsed.orderby + ? parsed.orderby.filter(o => o.expr.column !== column) + : null; + } + // If order by clause for column exists, update type + const colInOrderBy = parsed.orderby?.find(o => o.expr.column === column); + if (colInOrderBy) { + colInOrderBy.type = type; + return parsed.orderby; + } + // Otherwise, add new order by clause + const newOrderby = { + expr: { type: "column_ref", column }, + type, + }; + return parsed.orderby ? [...parsed.orderby, newOrderby] : [newOrderby]; +} + +// Gets the type of query +export function getQueryType(q: string): string | undefined { + let ast = {}; + try { + ast = parser.astify(q, opt); + } catch (err) { + console.error(getQueryError(q, err)); + return undefined; + } + const obj: AST | undefined = Array.isArray(ast) ? ast[0] : ast; + if (!obj) return undefined; + return obj.type; +} + +export function isMutation(q?: string): boolean { + if (!q) return false; + const type = getQueryType(q); + if (!type) { + const lower = q.toLowerCase(); + if ( + lower.startsWith("insert") || + lower.startsWith("alter") || + lower.startsWith("create") || + lower.startsWith("drop") || + lower.startsWith("update") || + lower.startsWith("revoke") || + lower.startsWith("grant") || + lower.startsWith("flush") || + (lower.startsWith("with") && + ((lower.includes("update") && lower.includes("set")) || + lower.includes("delete from"))) + ) { + return true; + } + } + return !!type && type !== "select" && type !== "desc" && type !== "show"; +} + +// Removes a column from a select query +export function removeColumnFromQuery( + q: string, + colNameToRemove: string, + cols: TableColumn[], +): string { + const newCols = cols.filter(c => c.name !== colNameToRemove); + const tableName = getTableNames(q); + return convertToSqlWithNewCols(q, newCols, tableName); +} + +function escapeSingleQuotes(value: string): string { + if (value.includes("\\'")) return value; + return value.replace(/'/g, "\\'"); +} + +// Query should be wrapped in single quotes +export function makeQueryExecutable(q: string): string { + return ( + q + // Escape single quotes + .replace(/'/g, "\\'") + // Remove newlines and carriage returns + .replace(/\r\n|\n|\r/gm, " ") + // Remove whitespace from beginning/end + .trim() + ); +} + +export function isMultipleQueries(queryString: string): boolean { + try { + const { ast } = parser.parse(queryString); + return Array.isArray(ast) && ast.length > 1; + } catch (err) { + return false; + } +} diff --git a/yarn.lock b/yarn.lock index d0d63141..8135b04d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1533,6 +1533,7 @@ __metadata: babel-jest: "npm:^29.7.0" eslint: "npm:^8.56.0" jest: "npm:^29.7.0" + node-sql-parser: "npm:^4.17.0" prettier: "npm:^3.1.0" rollup: "npm:^4.9.4" rollup-plugin-dts: "npm:^6.1.0" @@ -3075,6 +3076,13 @@ __metadata: languageName: node linkType: hard +"big-integer@npm:^1.6.48": + version: 1.6.52 + resolution: "big-integer@npm:1.6.52" + checksum: 9604224b4c2ab3c43c075d92da15863077a9f59e5d4205f4e7e76acd0cd47e8d469ec5e5dba8d9b32aa233951893b29329ca56ac80c20ce094b4a647a66abae0 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -6304,6 +6312,15 @@ __metadata: languageName: node linkType: hard +"node-sql-parser@npm:^4.17.0": + version: 4.17.0 + resolution: "node-sql-parser@npm:4.17.0" + dependencies: + big-integer: "npm:^1.6.48" + checksum: 151c8d1c4ad8b827f2ec9811f0c704a99e1521317990ffd25cad35c9775ee2112b9598b051eb85c582f23c74bb4f92e467be8ec8d6f01f9870a8744d8d873492 + languageName: node + linkType: hard + "nopt@npm:^7.0.0": version: 7.2.0 resolution: "nopt@npm:7.2.0" From c69cc46f0d5bb54fe959842f23f64293929ee64b Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 16:39:33 -0800 Subject: [PATCH 10/11] utils: Publish 0.1.2 --- packages/utils/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/utils/package.json b/packages/utils/package.json index 6090d508..d205b1a6 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -2,7 +2,7 @@ "name": "@dolthub/web-utils", "author": "DoltHub", "description": "A collection of utilities for building web applications", - "version": "0.1.0", + "version": "0.1.2", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "types": "dist/index.d.ts", From 3497d76354b04e77f7850ba8ea5da25f712cef2a Mon Sep 17 00:00:00 2001 From: Taylor Bantle Date: Tue, 16 Jan 2024 17:13:52 -0800 Subject: [PATCH 11/11] hooks: Fix hooks --- packages/hooks/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 40842b4c..3fa22905 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -33,7 +33,7 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@dolthub/web-utils": "0.1.0", + "@dolthub/web-utils": "^0.1.2", "js-cookie": "^3.0.5", "react-hotkeys": "^2.0.0" }, diff --git a/yarn.lock b/yarn.lock index 8135b04d..d4e97970 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1475,7 +1475,7 @@ __metadata: "@babel/preset-env": "npm:^7.23.8" "@babel/preset-react": "npm:^7.23.3" "@babel/preset-typescript": "npm:^7.23.3" - "@dolthub/web-utils": "npm:0.1.0" + "@dolthub/web-utils": "npm:^0.1.2" "@rollup/plugin-commonjs": "npm:^25.0.7" "@rollup/plugin-node-resolve": "npm:^15.2.3" "@rollup/plugin-typescript": "npm:^11.1.5" @@ -1515,7 +1515,7 @@ __metadata: languageName: unknown linkType: soft -"@dolthub/web-utils@npm:0.1.0, @dolthub/web-utils@workspace:packages/utils": +"@dolthub/web-utils@npm:^0.1.2, @dolthub/web-utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@dolthub/web-utils@workspace:packages/utils" dependencies: