diff --git a/api/src/category.repository.test.ts b/api/src/category.repository.test.ts index d03256b..c8933bb 100644 --- a/api/src/category.repository.test.ts +++ b/api/src/category.repository.test.ts @@ -1,40 +1,44 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { CategoryRepository } from './category.repository'; +import { CategoryRepository } from './category.repository.ts'; +import { Pool } from 'pg'; -describe('CategoryRepository - ltree SQL Generator', () => { - const repository = new CategoryRepository(); +// Lightweight mock generator for the pg Pool +function createMockDb(mockRows: any[]) { + const tracker = { lastQuery: { text: '', values: [] as any[] } }; + const mockPool = { + query: async (text: string, values: any[]) => { + tracker.lastQuery = { text, values }; + return { rows: mockRows }; + } + } as unknown as Pool; - describe('generateDescendantsQuery()', () => { - it('should generate valid SQL using the ltree descenant operator (<@)', () => { - const parentPath = 'Top.Science'; - const result = repository.generateDescendantsQuery(parentPath); + return { mockPool, tracker }; +} - // veriy the generated SQL syntax as correct ltree - assert.match(result.sql, /<@/); - assert.match(result.sql, /::ltree/); - assert.deepEqual(result.values, ['Top.Science']); - }); +describe('CategoryRepository - ltree Queries', () => { + it('should format findAllDescendants query using the ltree descendant operator (<@)', async () => { + const mockData = [{ id: 2, name: 'Science', path: 'Top.Science' }]; + const { mockPool, tracker } = createMockDb(mockData); + const repository = new CategoryRepository(mockPool); + + const result = await repository.findAllDescendants('Top.Science'); + + // Verify data output matches mock rows + assert.deepEqual(result, mockData); + + // Verify generated SQL uses the right operator and bindings + assert.match(tracker.lastQuery.text, /<@/); + assert.deepEqual(tracker.lastQuery.values, ['Top.Science']); }); - describe('generateWildcardQuery()', () => { - it('should generate valid SQL utilizing an lquery matching operator (~)', () => { - const result = repository.generateWildcardQuery('Top.*.Astronomy'); + it('should format findDirectChildren query with level matching requirements', async () => { + const { mockPool, tracker } = createMockDb([]); + const repository = new CategoryRepository(mockPool); - assert.match(result.sql, /~/); - assert.match(result.sql, /::lquery/); - assert.equal(result.values[0], 'Top.*.Astronomy'); - }); + await repository.findDirectChildren('Top'); + + assert.match(tracker.lastQuery.text, /nlevel\(path\)/); + assert.deepEqual(tracker.lastQuery.values, ['Top.*', 'Top']); }); - - describe('isValidLtreePath() validation utility', () => { - it('should pass for correctly formatted dot-separated ltree structures', () => { - assert.equal(repository.isValidLtreePath('Top.Hobbies.Amateurs_Astronomy'), true); - }); - - it('should fail if special characters or spaces are introduced into the path', () => { - assert.equal(repository.isValidLtreePath('Top.Hobbies.Bad Path!'), false); - assert.equal(repository.isValidLtreePath('Top..InvalidDoubleDot'), false); - }); - }); -}); \ No newline at end of file +}); diff --git a/api/src/category.repository.ts b/api/src/category.repository.ts index 22477c5..3afac7b 100644 --- a/api/src/category.repository.ts +++ b/api/src/category.repository.ts @@ -1,30 +1,58 @@ -// structural interface to the test table -export interface CatetoryNode { +import { Pool } from 'pg'; + +export interface CategoryNode { id: number; name: string; - path: string; // ltree field + path: string; } export class CategoryRepository { - // simulate generating a SQL query targeting child paths - generateDescendantsQuery(parentPath: string): { sql: string; values: string[] } { - return { - sql: 'SELECT id, name, path FROM categories WHERE path <@ $1::ltree', - values: [parentPath] - }; + // Inject the database connection pool + constructor(private db: Pool) { } + + /** + * Finds all descendants of a given path (including the path itself). + * Uses the ltree operator <@ (is-descendant-of). + */ + async findAllDescendants(parentPath: string): Promise { + const query = ` + SELECT id, name, path + FROM categories + WHERE path <@ $1::ltree + ORDER BY path ASC; + `; + const result = await this.db.query(query, [parentPath]); + return result.rows; } - generateWildcardQuery(pattern: string): { sql: string, values: string[] } { - // Simulate genrating a SQL query using ltree wildcards - return { - sql: 'SELECT id, name, path FROM categories WHERE path ~ $1::lquery', - values: [pattern] - }; + /** + * Finds only the direct children of a given path. + * Uses lquery pattern matching (parent.*) and matches the exact next depth level. + */ + async findDirectChildren(parentPath: string): Promise { + const query = ` + SELECT id, name, path + FROM categories + WHERE path ~ $1::lquery + AND nlevel(path) = nlevel($2::ltree) + 1; + `; + const values = [`${parentPath}.*`, parentPath]; + const result = await this.db.query(query, values); + return result.rows; } - // helper utility to validate a string as an ltree value - isValidLtreePath(path: string): boolean { - const ltreeRegex = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$/; - return ltreeRegex.test(path); + /** + * Finds all ancestors of a path (useful for breadcrumbs). + * Uses the ltree operator @> (is-ancestor-of). + */ + async findAncestors(childPath: string): Promise { + const query = ` + SELECT id, name, path + FROM categories + WHERE path @> $1::ltree + ORDER BY nlevel(path) ASC; + `; + const result = await this.db.query(query, [childPath]); + return result.rows; } -} \ No newline at end of file +}