This commit is contained in:
seb
2026-10-11 17:26:11 +02:00
parent 7ffb21aabb
commit 7f4092971d
17 changed files with 3076 additions and 2616 deletions

View File

@@ -2,6 +2,25 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `attributevalue.addImage`
*Source: `src/store/attributevalue/addImage.js`*
Adds an image to an attribute value, processing full, thumb, and 32px-edge small-thumb AVIF variants.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `attributeValueId` | `number` | The id of the attribute value. |
| `image` | `Buffer` | The source image buffer. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, pics: Array<object>}>`
## `attributevalue.create`
*Source: `src/store/attributevalue/create.js`*
@@ -49,6 +68,28 @@ Deletes an attribute value.
`Error` — If attribute value not found or in use.
## `attributevalue.deleteImage`
*Source: `src/store/attributevalue/deleteImage.js`*
Deletes the image of an attribute value.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `attributeValueId` | `number` | The id of the attribute value. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null, pics: Array}>`
**Throws**
`Error` — If attribute value not found or image not found.
## `attributevalue.listByAttributeId`
*Source: `src/store/attributevalue/listByAttributeId.js`*

3
API.md
View File

@@ -20,6 +20,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- **articletext**: `id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)`
- **attributename**: `id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)`
- **attributevalue**: `id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)`
- **attributevaluepic**: `id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)`
- **brand**: `id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)`
- **brandpic**: `id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)`
- **category**: `id PK, parent_id?->category, sort:i=0`
@@ -264,8 +265,10 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `order(googleAuthId: number, input: {attributenameIds: Array<number>}, tx?: object): Promise<{attributenameIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name?: string, sort?: number, exclusive?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>`
- [attributevalue](API.attributevalue.md)
- `addImage(googleAuthId: number, attributeValueId: number, image: Buffer, tx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, pics: Array<object>}>`
- `create(googleAuthId: number, attributeId: number, input: {value: string, sort?: number}, tx?: object): Promise<{id: number, attributeId: number, value: string, sort: number}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, attributeValueId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null, pics: Array}>`
- `listByAttributeId(googleAuthId: number, attributeId: number, tx?: object): Promise<Array<{id: number, attributeId: number, value: string, sort: number}>>`
- `order(googleAuthId: number, attributeId: number, input: {attributevalueIds: Array<number>}, tx?: object): Promise<{attributevalueIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {value?: string, sort?: number}, tx?: object): Promise<{id: number, attributeId: number, value: string, sort: number}>`

View File

@@ -331,6 +331,7 @@ const SCHEMA_TABLES = [
['articletext', 'id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)'],
['attributename', 'id PK, company_id->company, name:s(255), sort:i=0, exclusive:b=0, UQ(company_id, name)'],
['attributevalue', 'id PK, attribute_id->attributename, value:s(255), sort:i=0, UQ(attribute_id, value)'],
['attributevaluepic', 'id PK, attributevalue_id->attributevalue, pic_id->pic, sort:i=0, UQ(attributevalue_id, pic_id), UQ(attributevalue_id, sort)'],
['brand', 'id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)'],
['brandpic', 'id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)'],
['category', 'id PK, parent_id?->category, sort:i=0'],

View File

@@ -365,7 +365,7 @@ const CLUSTERS = [
{
id: 'media',
label: 'Media',
tables: ['pic', 'articlepic', 'categorypic', 'brandpic', 'file'],
tables: ['pic', 'articlepic', 'categorypic', 'brandpic', 'attributevaluepic', 'file'],
},
{
id: 'manifests',

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 320 KiB

View File

@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing attributevaluepic & attributevalue image operations ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-attrval-pic-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('tester@example.com');
const company = await store.company.create({
name: 'Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const attr = await store.attributename.create(googleAuthId, {
name: 'Color',
exclusive: true,
});
const val = await store.attributevalue.create(googleAuthId, attr.id, {
value: 'Midnight Blue',
});
assert.ok(val.id > 0);
// Create a 100x100 test image
const imageBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 4,
background: { r: 25, g: 25, b: 112, alpha: 1 },
},
})
.avif()
.toBuffer();
console.log('1. Adding image to attribute value...');
const addResult = await store.attributevalue.addImage(googleAuthId, val.id, imageBuffer);
assert.equal(addResult.id, val.id);
assert.ok(addResult.fullHash);
assert.ok(addResult.thumbW > 0);
assert.ok(addResult.thumbH > 0);
assert.ok(addResult.thumbHash);
assert.equal(addResult.pics.length, 1);
assert.equal(addResult.pics[0].fullHash, addResult.fullHash);
// Verify row in attributevaluepic
const rows = await provider.query('SELECT * FROM attributevaluepic WHERE attributevalue_id = @id', {
id: val.id,
});
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0].attributevalue_id, val.id);
// Add again to test replacement
console.log('2. Replacing image on attribute value...');
const replaceResult = await store.attributevalue.addImage(googleAuthId, val.id, imageBuffer);
assert.equal(replaceResult.id, val.id);
const rowsAfterReplace = await provider.query('SELECT * FROM attributevaluepic WHERE attributevalue_id = @id', {
id: val.id,
});
assert.equal(rowsAfterReplace.rows.length, 1);
// Delete image
console.log('3. Deleting image from attribute value...');
const deleteResult = await store.attributevalue.deleteImage(googleAuthId, val.id);
assert.equal(deleteResult.id, val.id);
assert.equal(deleteResult.thumbW, null);
assert.equal(deleteResult.thumbH, null);
assert.equal(deleteResult.thumbHash, null);
assert.deepEqual(deleteResult.pics, []);
const rowsAfterDelete = await provider.query('SELECT * FROM attributevaluepic WHERE attributevalue_id = @id', {
id: val.id,
});
assert.equal(rowsAfterDelete.rows.length, 0);
// Deleting again should fail with Image not found
await assert.rejects(
async () => {
await store.attributevalue.deleteImage(googleAuthId, val.id);
},
/Image not found/
);
console.log('All attributevaluepic tests passed successfully!');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -212,6 +212,8 @@ import attributevalueCreate from './store/attributevalue/create.js';
import attributevalueUpdate from './store/attributevalue/update.js';
import attributevalueOrder from './store/attributevalue/order.js';
import attributevalueDelete from './store/attributevalue/delete.js';
import attributevalueAddImage from './store/attributevalue/addImage.js';
import attributevalueDeleteImage from './store/attributevalue/deleteImage.js';
import articleattributevalueListByArticleSku from './store/articleattributevalue/listByArticleSku.js';
import articleattributevalueSetByArticleSku from './store/articleattributevalue/setByArticleSku.js';
import categorypropertyListByCategoryId from './store/categoryproperty/listByCategoryId.js';
@@ -574,6 +576,8 @@ export default class LibDashStore {
update: attributevalueUpdate(provider),
order: attributevalueOrder(provider),
delete: attributevalueDelete(provider),
addImage: attributevalueAddImage(provider),
deleteImage: attributevalueDeleteImage(provider),
};
this.articleattributevalue = {

View File

@@ -6,6 +6,7 @@ export const TABLES = [
'account',
'articleattributevalue',
'articleproperty',
'attributevaluepic',
'attributevalue',
'attributename',
'propertyname',

View File

@@ -0,0 +1,23 @@
export const GET_NEXT_SORT = `
SELECT ISNULL(MAX(sort), -1) + 1 AS sort
FROM attributevaluepic
WHERE attributevalue_id = @attributeValueId
`;
export const ADD = `
INSERT INTO attributevaluepic (attributevalue_id, pic_id, sort)
VALUES (@attributeValueId, @picId, @sort)
`;
export const DELETE_BY_ATTRIBUTE_VALUE = `
DELETE FROM attributevaluepic
WHERE attributevalue_id = @attributeValueId
`;
export const GET_FIRST_PIC_BY_ATTRIBUTE_VALUE = `
SELECT TOP 1 p.id AS picId, p.thumbw AS thumbW, p.thumbh AS thumbH, p.thumbhash AS thumbHash
FROM attributevaluepic avp
INNER JOIN pic p ON p.id = avp.pic_id
WHERE avp.attributevalue_id = @attributeValueId
ORDER BY avp.sort ASC, avp.id ASC
`;

View File

@@ -45,6 +45,16 @@ FROM (
INNER JOIN brand b ON b.id = bp.brand_id
INNER JOIN company c ON c.id = b.company_id
WHERE p.hash = @hash
UNION
SELECT c.root_category_id AS categoryId
FROM pic p
INNER JOIN attributevaluepic avp ON avp.pic_id = p.id
INNER JOIN attributevalue av ON av.id = avp.attributevalue_id
INNER JOIN attributename an ON an.id = av.attribute_id
INNER JOIN company c ON c.id = an.company_id
WHERE p.hash = @hash
) AS combined
WHERE categoryId IS NOT NULL
`;

View File

@@ -463,6 +463,22 @@ BEGIN
END
`;
export const INIT_ATTRIBUTEVALUEPIC = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'attributevaluepic')
BEGIN
CREATE TABLE attributevaluepic (
id INT IDENTITY(1,1) PRIMARY KEY,
attributevalue_id INT NOT NULL,
pic_id INT NOT NULL,
sort INT NOT NULL CONSTRAINT DF_attributevaluepic_sort DEFAULT 0,
CONSTRAINT FK_attributevaluepic_attributevalue FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE CASCADE,
CONSTRAINT FK_attributevaluepic_pic FOREIGN KEY (pic_id) REFERENCES pic(id) ON DELETE CASCADE,
CONSTRAINT UQ_attributevaluepic_attributevalue_pic UNIQUE (attributevalue_id, pic_id),
CONSTRAINT UQ_attributevaluepic_attributevalue_sort UNIQUE (attributevalue_id, sort)
);
END
`;
export const INIT_ARTICLELISTARTICLE = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articlelistarticle')
BEGIN
@@ -1183,6 +1199,7 @@ export const SCHEMA_SCRIPTS = [
INIT_ARTICLEEAN,
INIT_ARTICLEPIC,
INIT_BRANDPIC,
INIT_ATTRIBUTEVALUEPIC,
INIT_ARTICLELISTARTICLE,
INIT_OPTIONSLISTARTICLE,
INIT_CATEGORYARTICLE,

View File

@@ -0,0 +1,5 @@
import * as source from '../../mssql/queries/attributevaluepic.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'attributevaluepic');
export const { ADD, DELETE_BY_ATTRIBUTE_VALUE, GET_FIRST_PIC_BY_ATTRIBUTE_VALUE, GET_NEXT_SORT } = queries;

View File

@@ -284,6 +284,19 @@ CREATE TABLE IF NOT EXISTS brandpic (
);
`;
export const INIT_ATTRIBUTEVALUEPIC = `
CREATE TABLE IF NOT EXISTS attributevaluepic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
attributevalue_id INTEGER NOT NULL,
pic_id INTEGER NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
UNIQUE (attributevalue_id, pic_id),
UNIQUE (attributevalue_id, sort),
FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE CASCADE,
FOREIGN KEY (pic_id) REFERENCES pic(id) ON DELETE CASCADE
);
`;
export const INIT_ARTICLELISTARTICLE = `
CREATE TABLE IF NOT EXISTS articlelistarticle (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -853,6 +866,7 @@ export const SCHEMA_SCRIPTS = [
INIT_ARTICLEEAN,
INIT_ARTICLEPIC,
INIT_BRANDPIC,
INIT_ATTRIBUTEVALUEPIC,
INIT_ARTICLELISTARTICLE,
INIT_OPTIONSLISTARTICLE,
INIT_CATEGORYARTICLE,

View File

@@ -0,0 +1,118 @@
import {
ADD as MSSQL_ADD_AVPIC,
DELETE_BY_ATTRIBUTE_VALUE as MSSQL_DELETE_AVPIC,
} from '../../providers/mssql/queries/attributevaluepic.js';
import {
ADD as SQLITE_ADD_AVPIC,
DELETE_BY_ATTRIBUTE_VALUE as SQLITE_DELETE_AVPIC,
} from '../../providers/sqlite3/queries/attributevaluepic.js';
import { CREATE as MSSQL_CREATE_PIC } from '../../providers/mssql/queries/pic.js';
import { CREATE as SQLITE_CREATE_PIC } from '../../providers/sqlite3/queries/pic.js';
import processAvifImage from '../../util/processAvifImage.js';
import newFullHash from '../../util/newFullHash.js';
import resolveAttributeValueAccess from './resolveAttributeValueAccess.js';
import makeRunner from '../runner.js';
export default function addImage(provider) {
const access = resolveAttributeValueAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const deleteAvPicSql = isSqlite ? SQLITE_DELETE_AVPIC : MSSQL_DELETE_AVPIC;
const createPicSql = isSqlite ? SQLITE_CREATE_PIC : MSSQL_CREATE_PIC;
const addAvPicSql = isSqlite ? SQLITE_ADD_AVPIC : MSSQL_ADD_AVPIC;
/**
* Adds an image to an attribute value, processing full, thumb, and 32px-edge small-thumb AVIF variants.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} attributeValueId - The id of the attribute value.
* @param {Buffer} image - The source image buffer.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, pics: Array<object>}>}
*/
return async (googleAuthId, attributeValueId, image, tx) => {
const runAdd = async (runner) => {
await access(googleAuthId, attributeValueId, runner);
const processed = await processAvifImage(image);
// Clean existing attributevaluepic so the attribute value has 1 primary image
await runner.query(deleteAvPicSql, { attributeValueId });
const sort = 0;
const fullHash = newFullHash(processed.full.buffer);
let picId;
const existingPic = await runner.query(
'SELECT id FROM pic WHERE hash = @fullHash',
{ fullHash },
);
if (existingPic.rows[0]) {
picId = existingPic.rows[0].id;
} else {
if (isSqlite) {
if (runner.writePicFull) {
await runner.writePicFull(fullHash, processed.full.buffer);
await runner.writePicThumb(fullHash, processed.thumb.buffer);
} else if (provider?.writePicFull) {
await provider.writePicFull(fullHash, processed.full.buffer);
await provider.writePicThumb(fullHash, processed.thumb.buffer);
}
}
const picResult = await runner.query(createPicSql, {
w: processed.full.w,
h: processed.full.h,
size: processed.full.size,
hash: fullHash,
thumbw: processed.thumb.w,
thumbh: processed.thumb.h,
thumbhash: processed.thumb.hash,
smallthumbw: processed.smallThumb.w,
smallthumbh: processed.smallThumb.h,
smallthumbbinary: processed.smallThumb.buffer,
source: '',
fullbinary: processed.full.buffer,
thumbbinary: processed.thumb.buffer,
});
picId = picResult.rows[0]?.id ?? picResult.insertId;
}
await runner.query(addAvPicSql, {
attributeValueId,
picId,
sort,
});
const picObj = {
fullHash,
thumbW: processed.thumb.w,
thumbH: processed.thumb.h,
thumbHash: processed.thumb.hash,
smallThumbW: processed.smallThumb.w,
smallThumbH: processed.smallThumb.h,
};
return {
id: attributeValueId,
fullHash,
thumbW: processed.thumb.w,
thumbH: processed.thumb.h,
thumbHash: processed.thumb.hash,
smallThumbW: processed.smallThumb.w,
smallThumbH: processed.smallThumb.h,
pics: [picObj],
};
};
if (tx) {
return runAdd(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(runAdd);
}
return runAdd(makeRunner(provider));
};
}

View File

@@ -0,0 +1,37 @@
import { DELETE_BY_ATTRIBUTE_VALUE as MSSQL_DELETE } from '../../providers/mssql/queries/attributevaluepic.js';
import { DELETE_BY_ATTRIBUTE_VALUE as SQLITE_DELETE } from '../../providers/sqlite3/queries/attributevaluepic.js';
import resolveAttributeValueAccess from './resolveAttributeValueAccess.js';
import makeRunner from '../runner.js';
export default function deleteImage(provider) {
const access = resolveAttributeValueAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes the image of an attribute value.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} attributeValueId - The id of the attribute value.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null, pics: Array}>}
* @throws {Error} If attribute value not found or image not found.
*/
return async (googleAuthId, attributeValueId, tx) => {
const runner = makeRunner(provider, tx);
await access(googleAuthId, attributeValueId, runner);
const result = await runner.query(sql, { attributeValueId });
if (!result.rowsAffected) {
throw new Error('Image not found');
}
return {
id: attributeValueId,
thumbW: null,
thumbH: null,
thumbHash: null,
pics: [],
};
};
}

View File

@@ -0,0 +1,22 @@
import { GET_BY_ID_AND_COMPANY as MSSQL_GET } from '../../providers/mssql/queries/attributevalue.js';
import { GET_BY_ID_AND_COMPANY as SQLITE_GET } from '../../providers/sqlite3/queries/attributevalue.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function resolveAttributeValueAccess(provider) {
const access = resolveSelectedCompanyAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_GET : MSSQL_GET;
return async (googleAuthId, attributeValueId, tx) => {
if (!Number.isFinite(attributeValueId) || attributeValueId <= 0) {
throw new Error('Attribute value not found');
}
const { companyId } = await access(googleAuthId, tx);
const runner = makeRunner(provider, tx);
const result = await runner.query(sql, { id: attributeValueId, companyId });
if (!result.rows[0]) {
throw new Error('Attribute value not found');
}
return { companyId, attributeValue: result.rows[0] };
};
}