From 6bdac7e543a5bfc0789e0d52fca2210579a4dc81 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 31 Jul 2026 00:08:50 +0200 Subject: [PATCH] u --- package-lock.json | 80 +++++++++- package.json | 5 +- schema-graph.dot | 2 +- schema-graph.svg | 39 ++--- scripts/init-schema.js | 25 +++ scripts/test-api.js | 193 ++++++++++++++++++++++++ src/index.js | 30 ++++ src/schema.js | 3 +- src/sql/category.js | 16 ++ src/sql/company.js | 10 ++ src/sql/companyuser.js | 6 + src/sql/purge.js | 49 ++++++ src/store/category/getTree.js | 38 +++++ src/store/company/getByCompanyUserId.js | 16 ++ src/store/companyuser/getByEmail.js | 13 ++ src/store/initialize.js | 12 ++ src/store/purge.js | 7 + 17 files changed, 520 insertions(+), 24 deletions(-) create mode 100644 scripts/init-schema.js create mode 100644 scripts/test-api.js create mode 100644 src/index.js create mode 100644 src/sql/category.js create mode 100644 src/sql/company.js create mode 100644 src/sql/companyuser.js create mode 100644 src/sql/purge.js create mode 100644 src/store/category/getTree.js create mode 100644 src/store/company/getByCompanyUserId.js create mode 100644 src/store/companyuser/getByEmail.js create mode 100644 src/store/initialize.js create mode 100644 src/store/purge.js diff --git a/package-lock.json b/package-lock.json index 1adc9ef..d2ec25e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { - "name": "dashdbschema", - "version": "1.0.0", + "name": "libdashstore", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dashdbschema", - "version": "1.0.0", + "name": "libdashstore", + "version": "0.1.0", "license": "ISC", "dependencies": { + "@clack/prompts": "^1.7.0", "@hpcc-js/wasm-graphviz": "^1.28.0", + "chalk": "^6.0.0", "dotenv": "^17.4.2", "mssql": "^12.7.0" } @@ -256,6 +258,34 @@ "node": ">=20" } }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@hpcc-js/wasm-graphviz": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.28.0.tgz", @@ -404,6 +434,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -509,6 +551,30 @@ "node": ">=0.8.x" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -840,6 +906,12 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", diff --git a/package.json b/package.json index 9fbbf6c..274fc37 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ }, "scripts": { "schema-graph": "node generate-schema-graph.js", - "init-schema": "node scripts/init-schema.js" + "init-schema": "node scripts/init-schema.js", + "test:api": "node scripts/test-api.js" }, "dependencies": { + "@clack/prompts": "^1.7.0", "@hpcc-js/wasm-graphviz": "^1.28.0", + "chalk": "^6.0.0", "dotenv": "^17.4.2", "mssql": "^12.7.0" } diff --git a/schema-graph.dot b/schema-graph.dot index 34f843e..fc6dc69 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -14,7 +14,7 @@ digraph schema { company [label=<
company
idINT PK
default_language_id?INT FK
root_category_id?INT FK
nameNVARCHAR(255)
>]; numbers [label=<
numbers
idINT PK
company_idINT FK
typeNVARCHAR(100)
prefixNVARCHAR(50)
paddingINT
nextINT
>]; entity [label=<
entity
idINT PK
customergroup_idINT FK
firstnameNVARCHAR(255)
lastnameNVARCHAR(255)
>]; - companyuser [label=<
companyuser
company_idINT FK
google_auth_idINT FK
>]; + companyuser [label=<
companyuser
idINT PK
company_idINT FK
google_auth_idINT FK
>]; google_auth [label=<
google_auth
idINT PK
emailNVARCHAR(320)
created_atDATETIME2
>]; } subgraph cluster_i18n { diff --git a/schema-graph.svg b/schema-graph.svg index 8231a3b..7ee94ce 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -261,26 +261,31 @@ companyuser - - -companyuser - - -company_id -INT -FK - - -google_auth_id -INT -FK + + +companyuser + + +id +INT +PK + + +company_id +INT +FK + + +google_auth_id +INT +FK companyuser:sw->company:n - - + + @@ -309,8 +314,8 @@ companyuser:se->google_auth:n - - + + diff --git a/scripts/init-schema.js b/scripts/init-schema.js new file mode 100644 index 0000000..6ff1d36 --- /dev/null +++ b/scripts/init-schema.js @@ -0,0 +1,25 @@ +import 'dotenv/config'; +import sql from 'mssql'; +import { SCHEMA_SCRIPTS } from '../src/schema.js'; + +const config = { + server: process.env.MSSQL_SERVER, + database: process.env.MSSQL_DATABASE, + user: process.env.MSSQL_USER, + password: process.env.MSSQL_PASSWORD, + options: { + encrypt: false, + trustServerCertificate: true, + }, +}; + +const pool = await sql.connect(config); + +try { + for (const script of SCHEMA_SCRIPTS) { + await pool.request().query(script); + } + console.log(`Schema init OK (${SCHEMA_SCRIPTS.length} scripts)`); +} finally { + await pool.close(); +} diff --git a/scripts/test-api.js b/scripts/test-api.js new file mode 100644 index 0000000..64b4a6d --- /dev/null +++ b/scripts/test-api.js @@ -0,0 +1,193 @@ +import 'dotenv/config'; +import sql from 'mssql'; +import chalk from 'chalk'; +import * as p from '@clack/prompts'; +import LibDashStore from '../src/index.js'; + +const config = { + server: process.env.MSSQL_SERVER, + database: process.env.MSSQL_DATABASE, + user: process.env.MSSQL_USER, + password: process.env.MSSQL_PASSWORD, + options: { + encrypt: false, + trustServerCertificate: true, + }, +}; + +function printResult(label, value) { + p.log.step(chalk.cyan(label)); + const text = + value === undefined + ? chalk.dim('undefined') + : typeof value === 'string' + ? value + : JSON.stringify(value, null, 2); + console.log(chalk.white(text)); + console.log(); +} + +async function shutdown(pool, code = 0) { + try { + await pool.close(); + } catch { + // ignore + } + process.exit(code); +} + +async function main() { + console.clear(); + p.intro(chalk.bgMagenta.black(' libdashstore ') + chalk.dim(' api tester')); + + const spin = p.spinner(); + spin.start('Connecting to MSSQL…'); + + let pool; + try { + pool = await sql.connect(config); + } catch (err) { + spin.stop(chalk.red('Connection failed')); + p.log.error(err.message); + p.outro(chalk.red('Aborted')); + process.exit(1); + } + + spin.message('Initializing schema…'); + let client; + try { + client = await new LibDashStore(pool); + spin.stop(chalk.green('Ready')); + } catch (err) { + spin.stop(chalk.red('Initialize failed')); + p.log.error(err.message); + await shutdown(pool, 1); + } + + p.note( + [ + chalk.dim(`${config.server} / ${config.database}`), + chalk.dim('purge ends the session'), + ].join('\n'), + 'Connected', + ); + + while (true) { + const action = await p.select({ + message: 'API method', + options: [ + { + value: 'companyuser.getByEmail', + label: 'companyuser.getByEmail', + hint: 'email → id', + }, + { + value: 'company.getByCompanyUserId', + label: 'company.getByCompanyUserId', + hint: 'id → [id, lang, root, name]', + }, + { + value: 'category.getTree', + label: 'category.getTree', + hint: 'rootId → tree', + }, + { + value: 'initialize', + label: 'initialize', + hint: 'purge + recreate schema', + }, + { + value: 'purge', + label: chalk.red('purge'), + hint: 'drop all · exit', + }, + { value: 'exit', label: 'exit', hint: 'quit without purge' }, + ], + }); + + if (p.isCancel(action) || action === 'exit') { + p.outro(chalk.dim('Bye')); + await shutdown(pool, 0); + } + + try { + if (action === 'companyuser.getByEmail') { + const email = await p.text({ + message: 'Email', + placeholder: 'user@example.com', + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (p.isCancel(email)) continue; + + const run = p.spinner(); + run.start('Running…'); + const id = await client.companyuser.getByEmail(email.trim()); + run.stop(chalk.green('companyuser.getByEmail')); + printResult('id', id); + continue; + } + + if (action === 'company.getByCompanyUserId') { + const raw = await p.text({ + message: 'Company user id', + placeholder: '1', + validate: (v) => + !/^\d+$/.test(v?.trim() ?? '') ? 'Integer required' : undefined, + }); + if (p.isCancel(raw)) continue; + + const run = p.spinner(); + run.start('Running…'); + const row = await client.company.getByCompanyUserId(Number(raw.trim())); + run.stop(chalk.green('company.getByCompanyUserId')); + printResult('[id, defaultLangId, rootCategoryId, name]', row); + continue; + } + + if (action === 'category.getTree') { + const raw = await p.text({ + message: 'Root category id', + placeholder: '1', + validate: (v) => + !/^\d+$/.test(v?.trim() ?? '') ? 'Integer required' : undefined, + }); + if (p.isCancel(raw)) continue; + + const run = p.spinner(); + run.start('Running…'); + const tree = await client.category.getTree(Number(raw.trim())); + run.stop(chalk.green('category.getTree')); + printResult('tree', tree); + continue; + } + + if (action === 'initialize') { + const run = p.spinner(); + run.start('Initializing…'); + await client.initialize(); + run.stop(chalk.green('initialize')); + p.log.success('Schema purged and recreated'); + console.log(); + continue; + } + + if (action === 'purge') { + const run = p.spinner(); + run.start('Purging…'); + await client.purge(); + run.stop(chalk.green('purge')); + p.outro(chalk.red('Database purged — exiting')); + await shutdown(pool, 0); + } + } catch (err) { + p.log.error(err.message); + console.log(chalk.dim(err.stack?.split('\n').slice(1, 4).join('\n') ?? '')); + console.log(); + } + } +} + +main().catch(async (err) => { + console.error(chalk.red(err.stack ?? err.message)); + process.exit(1); +}); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e7a6c91 --- /dev/null +++ b/src/index.js @@ -0,0 +1,30 @@ +import companyuserGetByEmail from './store/companyuser/getByEmail.js'; +import companyGetByCompanyUserId from './store/company/getByCompanyUserId.js'; +import categoryGetTree from './store/category/getTree.js'; +import storePurge from './store/purge.js'; +import storeInitialize from './store/initialize.js'; + +export default class LibDashStore { + constructor(pool) { + this.pool = pool; + + this.companyuser = { + getByEmail: companyuserGetByEmail(pool), + }; + + this.company = { + getByCompanyUserId: companyGetByCompanyUserId(pool), + }; + + this.category = { + getTree: categoryGetTree(pool), + }; + + this.purge = storePurge(pool); + this.initialize = storeInitialize(pool); + + return this.initialize().then(() => this); + } +} + +export { SCHEMA_SCRIPTS } from './schema.js'; diff --git a/src/schema.js b/src/schema.js index fc2382f..2504a9a 100644 --- a/src/schema.js +++ b/src/schema.js @@ -487,9 +487,10 @@ export const INIT_COMPANYUSER = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'companyuser') BEGIN CREATE TABLE companyuser ( + id INT IDENTITY(1,1) PRIMARY KEY, google_auth_id INT NOT NULL, company_id INT NOT NULL, - CONSTRAINT PK_companyuser PRIMARY KEY (google_auth_id, company_id), + CONSTRAINT UQ_companyuser_google_auth_company UNIQUE (google_auth_id, company_id), CONSTRAINT FK_companyuser_google_auth FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE, CONSTRAINT FK_companyuser_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE ); diff --git a/src/sql/category.js b/src/sql/category.js new file mode 100644 index 0000000..b602e38 --- /dev/null +++ b/src/sql/category.js @@ -0,0 +1,16 @@ +export const GET_TREE = ` +WITH tree AS ( + SELECT id, parent_id, sort + FROM category + WHERE id = @rootId + + UNION ALL + + SELECT c.id, c.parent_id, c.sort + FROM category c + INNER JOIN tree t ON c.parent_id = t.id +) +SELECT id, parent_id, sort +FROM tree +ORDER BY sort, id +`; diff --git a/src/sql/company.js b/src/sql/company.js new file mode 100644 index 0000000..0acdc63 --- /dev/null +++ b/src/sql/company.js @@ -0,0 +1,10 @@ +export const GET_BY_COMPANY_USER_ID = ` +SELECT + c.id, + c.default_language_id, + c.root_category_id, + c.name +FROM company c +INNER JOIN companyuser cu ON cu.company_id = c.id +WHERE cu.id = @companyUserId +`; diff --git a/src/sql/companyuser.js b/src/sql/companyuser.js new file mode 100644 index 0000000..7e5ca08 --- /dev/null +++ b/src/sql/companyuser.js @@ -0,0 +1,6 @@ +export const GET_BY_EMAIL = ` +SELECT cu.id +FROM companyuser cu +INNER JOIN google_auth ga ON ga.id = cu.google_auth_id +WHERE ga.email = @email +`; diff --git a/src/sql/purge.js b/src/sql/purge.js new file mode 100644 index 0000000..23647fe --- /dev/null +++ b/src/sql/purge.js @@ -0,0 +1,49 @@ +/** Drop order = reverse of schema create dependency order. */ +export const TABLES = [ + 'companyuser', + 'numbers', + 'manifestarticle', + 'manifest', + 'price', + 'articletext', + 'categorytext', + 'categorypic', + 'categoryarticle', + 'optionslistarticle', + 'articlelistarticle', + 'brandpic', + 'articlepic', + 'articleean', + 'articlehistory', + 'article', + 'brand', + 'articlelist', + 'optionslist', + 'unittext', + 'unit', + 'entity', + 'customergroup', + 'company', + 'pic', + 'category', + 'tax', + 'language', + 'google_auth', + 'meta', +]; + +export const PROCEDURES = ['getnextnumberwithprefixandpadding']; + +export function buildPurgeSql() { + const drops = [ + ...PROCEDURES.map( + (name) => + `IF OBJECT_ID(N'dbo.${name}', N'P') IS NOT NULL DROP PROCEDURE dbo.[${name}];`, + ), + ...TABLES.map( + (name) => + `IF OBJECT_ID(N'dbo.${name}', N'U') IS NOT NULL DROP TABLE dbo.[${name}];`, + ), + ]; + return drops.join('\n'); +} diff --git a/src/store/category/getTree.js b/src/store/category/getTree.js new file mode 100644 index 0000000..caafcb6 --- /dev/null +++ b/src/store/category/getTree.js @@ -0,0 +1,38 @@ +import sql from 'mssql'; +import { GET_TREE } from '../../sql/category.js'; + +function buildTree(rows, rootId) { + const byId = new Map(); + for (const row of rows) { + byId.set(row.id, { + id: row.id, + parent_id: row.parent_id, + sort: row.sort, + children: [], + }); + } + + let root = null; + for (const node of byId.values()) { + if (node.id === rootId) { + root = node; + continue; + } + const parent = byId.get(node.parent_id); + if (parent) parent.children.push(node); + } + + return root; +} + +export default function getTree(pool) { + return async (rootId) => { + const result = await pool + .request() + .input('rootId', sql.Int, rootId) + .query(GET_TREE); + + if (result.recordset.length === 0) return null; + return buildTree(result.recordset, rootId); + }; +} diff --git a/src/store/company/getByCompanyUserId.js b/src/store/company/getByCompanyUserId.js new file mode 100644 index 0000000..a430f8d --- /dev/null +++ b/src/store/company/getByCompanyUserId.js @@ -0,0 +1,16 @@ +import sql from 'mssql'; +import { GET_BY_COMPANY_USER_ID } from '../../sql/company.js'; + +export default function getByCompanyUserId(pool) { + return async (companyUserId) => { + const result = await pool + .request() + .input('companyUserId', sql.Int, companyUserId) + .query(GET_BY_COMPANY_USER_ID); + + const row = result.recordset[0]; + if (!row) return null; + + return [row.id, row.default_language_id, row.root_category_id, row.name]; + }; +} diff --git a/src/store/companyuser/getByEmail.js b/src/store/companyuser/getByEmail.js new file mode 100644 index 0000000..171ff88 --- /dev/null +++ b/src/store/companyuser/getByEmail.js @@ -0,0 +1,13 @@ +import sql from 'mssql'; +import { GET_BY_EMAIL } from '../../sql/companyuser.js'; + +export default function getByEmail(pool) { + return async (email) => { + const result = await pool + .request() + .input('email', sql.NVarChar(320), email) + .query(GET_BY_EMAIL); + + return result.recordset[0]?.id ?? null; + }; +} diff --git a/src/store/initialize.js b/src/store/initialize.js new file mode 100644 index 0000000..7bd731a --- /dev/null +++ b/src/store/initialize.js @@ -0,0 +1,12 @@ +import { SCHEMA_SCRIPTS } from '../schema.js'; +import purge from './purge.js'; + +export default function initialize(pool) { + const doPurge = purge(pool); + return async () => { + await doPurge(); + for (const script of SCHEMA_SCRIPTS) { + await pool.request().query(script); + } + }; +} diff --git a/src/store/purge.js b/src/store/purge.js new file mode 100644 index 0000000..b4bdafc --- /dev/null +++ b/src/store/purge.js @@ -0,0 +1,7 @@ +import { buildPurgeSql } from '../sql/purge.js'; + +export default function purge(pool) { + return async () => { + await pool.request().query(buildPurgeSql()); + }; +}