diff --git a/packages/ltree-helpers/LICENSE b/packages/ltree-helpers/LICENSE new file mode 100644 index 00000000..7b18c918 --- /dev/null +++ b/packages/ltree-helpers/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2025 Dan Lynch +Copyright (c) 2025 Constructive + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ltree-helpers/Makefile b/packages/ltree-helpers/Makefile new file mode 100644 index 00000000..ec5aa1b9 --- /dev/null +++ b/packages/ltree-helpers/Makefile @@ -0,0 +1,6 @@ +EXTENSION = pgpm-ltree-helpers +DATA = sql/pgpm-ltree-helpers--0.21.0.sql + +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) diff --git a/packages/ltree-helpers/README.md b/packages/ltree-helpers/README.md new file mode 100644 index 00000000..eceee569 --- /dev/null +++ b/packages/ltree-helpers/README.md @@ -0,0 +1,75 @@ +# @pgpm/ltree-helpers + +

+ +

+ +

+ + + + + +

+ +Slash-path to ltree/lquery conversion helpers for PostgreSQL. + +## Overview + +`@pgpm/ltree-helpers` provides simple SQL functions for converting between user-facing slash-delimited paths (like `/projects/alpha/docs`) and PostgreSQL's `ltree`/`lquery` types. This keeps ltree as an implementation detail while exposing a familiar filesystem-style path API. + +## Features + +- **Slash to ltree**: Convert `/projects/alpha/docs` to `projects.alpha.docs` +- **ltree to slash**: Convert `projects.alpha.docs` to `/projects/alpha/docs` +- **Glob to lquery**: Convert `/projects/*/docs` to `projects.*.docs` and `/**` to `.*{1,}` +- **Pure SQL**: All functions are `IMMUTABLE STRICT` for maximum performance and plan caching +- **Own schema**: Functions live in the `ltree_helpers` schema, not in `public` + +## Installation + +```bash +cd packages/my-module +pgpm install @pgpm/ltree-helpers +``` + +## Usage + +```sql +-- Slash path to ltree +SELECT ltree_helpers.to_path('/projects/alpha/docs'); +-- => 'projects.alpha.docs'::ltree + +-- ltree to slash path +SELECT ltree_helpers.to_slash('projects.alpha.docs'::ltree); +-- => '/projects/alpha/docs' + +-- Glob to lquery (single-level wildcard) +SELECT ltree_helpers.to_query('/projects/*/docs'); +-- => 'projects.*.docs'::lquery + +-- Glob to lquery (recursive wildcard) +SELECT ltree_helpers.to_query('/projects/**'); +-- => 'projects.*{1,}'::lquery + +-- Use with ltree operators +SELECT * FROM files +WHERE path <@ ltree_helpers.to_path('/projects/alpha'); + +-- Glob matching +SELECT * FROM files +WHERE path ~ ltree_helpers.to_query('/projects/*/docs'); +``` + +## API + +| Function | Signature | Description | +|----------|-----------|-------------| +| `ltree_helpers.to_path` | `(text) -> ltree` | Slash path to ltree | +| `ltree_helpers.to_slash` | `(ltree) -> text` | ltree to slash path | +| `ltree_helpers.to_query` | `(text) -> lquery` | Glob pattern to lquery | + +## Dependencies + +- `ltree` (PostgreSQL contrib extension) +- `pgpm-verify` diff --git a/packages/ltree-helpers/__tests__/ltree-helpers.test.ts b/packages/ltree-helpers/__tests__/ltree-helpers.test.ts new file mode 100644 index 00000000..fe91f320 --- /dev/null +++ b/packages/ltree-helpers/__tests__/ltree-helpers.test.ts @@ -0,0 +1,92 @@ +import { getConnections, PgTestClient } from 'pgsql-test'; + +let pg: PgTestClient; +let teardown: () => Promise; + +beforeAll(async () => { + ({ pg, teardown } = await getConnections()); +}); + +afterAll(async () => { + await teardown(); +}); + +describe('to_path', () => { + it('converts slash path with leading slash', async () => { + const { to_path } = await pg.one( + `SELECT ltree_helpers.to_path($1)::text AS to_path`, + ['/projects/alpha/docs'] + ); + expect(to_path).toBe('projects.alpha.docs'); + }); + + it('converts slash path without leading slash', async () => { + const { to_path } = await pg.one( + `SELECT ltree_helpers.to_path($1)::text AS to_path`, + ['projects/alpha'] + ); + expect(to_path).toBe('projects.alpha'); + }); + + it('converts single segment', async () => { + const { to_path } = await pg.one( + `SELECT ltree_helpers.to_path($1)::text AS to_path`, + ['/root'] + ); + expect(to_path).toBe('root'); + }); +}); + +describe('to_slash', () => { + it('converts ltree to slash path', async () => { + const { to_slash } = await pg.one( + `SELECT ltree_helpers.to_slash($1::ltree) AS to_slash`, + ['projects.alpha.docs'] + ); + expect(to_slash).toBe('/projects/alpha/docs'); + }); + + it('converts single label', async () => { + const { to_slash } = await pg.one( + `SELECT ltree_helpers.to_slash($1::ltree) AS to_slash`, + ['root'] + ); + expect(to_slash).toBe('/root'); + }); +}); + +describe('to_query', () => { + it('converts single-level wildcard', async () => { + const { to_query } = await pg.one( + `SELECT ltree_helpers.to_query($1)::text AS to_query`, + ['/projects/*/docs'] + ); + expect(to_query).toBe('projects.*.docs'); + }); + + it('converts recursive wildcard', async () => { + const { to_query } = await pg.one( + `SELECT ltree_helpers.to_query($1)::text AS to_query`, + ['/projects/**'] + ); + expect(to_query).toBe('projects.*{1,}'); + }); + + it('converts exact path (no wildcards)', async () => { + const { to_query } = await pg.one( + `SELECT ltree_helpers.to_query($1)::text AS to_query`, + ['/projects/alpha'] + ); + expect(to_query).toBe('projects.alpha'); + }); +}); + +describe('roundtrip', () => { + it('to_path then to_slash returns original path', async () => { + const { roundtrip } = await pg.one( + `SELECT ltree_helpers.to_slash(ltree_helpers.to_path($1)) AS roundtrip`, + ['/projects/alpha/docs'] + ); + expect(roundtrip).toBe('/projects/alpha/docs'); + }); +}); diff --git a/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_path.sql b/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_path.sql new file mode 100644 index 00000000..b6c16785 --- /dev/null +++ b/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_path.sql @@ -0,0 +1,16 @@ +-- Deploy schemas/ltree_helpers/procedures/to_path to pg + +-- requires: schemas/ltree_helpers/schema + +BEGIN; + +-- Convert a slash-delimited path to an ltree value. +-- '/projects/alpha/docs' => 'projects.alpha.docs' +-- 'projects/alpha' => 'projects.alpha' +CREATE FUNCTION ltree_helpers.to_path( + slash_path text +) RETURNS ltree AS $$ + SELECT replace(ltrim(slash_path, '/'), '/', '.')::ltree; +$$ LANGUAGE sql IMMUTABLE STRICT; + +COMMIT; diff --git a/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_query.sql b/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_query.sql new file mode 100644 index 00000000..ff75a6e2 --- /dev/null +++ b/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_query.sql @@ -0,0 +1,19 @@ +-- Deploy schemas/ltree_helpers/procedures/to_query to pg + +-- requires: schemas/ltree_helpers/schema + +BEGIN; + +-- Convert a glob-style path to an lquery value. +-- '/projects/*/docs' => 'projects.*.docs' +-- '/projects/**' => 'projects.*{1,}' +CREATE FUNCTION ltree_helpers.to_query( + glob text +) RETURNS lquery AS $$ + SELECT replace( + replace(ltrim(glob, '/'), '**', '*{1,}'), + '/', '.' + )::lquery; +$$ LANGUAGE sql IMMUTABLE STRICT; + +COMMIT; diff --git a/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_slash.sql b/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_slash.sql new file mode 100644 index 00000000..2e971530 --- /dev/null +++ b/packages/ltree-helpers/deploy/schemas/ltree_helpers/procedures/to_slash.sql @@ -0,0 +1,15 @@ +-- Deploy schemas/ltree_helpers/procedures/to_slash to pg + +-- requires: schemas/ltree_helpers/schema + +BEGIN; + +-- Convert an ltree value to a slash-delimited path. +-- 'projects.alpha.docs' => '/projects/alpha/docs' +CREATE FUNCTION ltree_helpers.to_slash( + lpath ltree +) RETURNS text AS $$ + SELECT '/' || replace(lpath::text, '.', '/'); +$$ LANGUAGE sql IMMUTABLE STRICT; + +COMMIT; diff --git a/packages/ltree-helpers/deploy/schemas/ltree_helpers/schema.sql b/packages/ltree-helpers/deploy/schemas/ltree_helpers/schema.sql new file mode 100644 index 00000000..199cf6b5 --- /dev/null +++ b/packages/ltree-helpers/deploy/schemas/ltree_helpers/schema.sql @@ -0,0 +1,15 @@ +-- Deploy schemas/ltree_helpers/schema to pg + + +BEGIN; + +CREATE SCHEMA ltree_helpers; + +GRANT USAGE ON SCHEMA ltree_helpers +TO authenticated, anonymous; + +ALTER DEFAULT PRIVILEGES IN SCHEMA ltree_helpers +GRANT EXECUTE ON FUNCTIONS +TO authenticated; + +COMMIT; diff --git a/packages/ltree-helpers/jest.config.js b/packages/ltree-helpers/jest.config.js new file mode 100644 index 00000000..e20e7efb --- /dev/null +++ b/packages/ltree-helpers/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + + // Match both __tests__ and colocated test files + testMatch: ['**/?(*.)+(test|spec).{ts,tsx,js,jsx}'], + + // Ignore build artifacts and type declarations + testPathIgnorePatterns: ['/dist/', '\\.d\\.ts$'], + modulePathIgnorePatterns: ['/dist/'], + watchPathIgnorePatterns: ['/dist/'], + + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], +}; diff --git a/packages/ltree-helpers/package.json b/packages/ltree-helpers/package.json new file mode 100644 index 00000000..c3313192 --- /dev/null +++ b/packages/ltree-helpers/package.json @@ -0,0 +1,38 @@ +{ + "name": "@pgpm/ltree-helpers", + "version": "0.21.0", + "description": "Slash-path to ltree/lquery conversion helpers for PostgreSQL", + "author": "Dan Lynch ", + "contributors": [ + "Constructive " + ], + "keywords": [ + "postgresql", + "pgpm", + "ltree", + "filesystem", + "paths" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "bundle": "pgpm package", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@pgpm/verify": "workspace:*" + }, + "devDependencies": { + "pgpm": "^4.16.6" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/pgpm-modules" + }, + "homepage": "https://github.com/constructive-io/pgpm-modules", + "bugs": { + "url": "https://github.com/constructive-io/pgpm-modules/issues" + } +} \ No newline at end of file diff --git a/packages/ltree-helpers/pgpm-ltree-helpers.control b/packages/ltree-helpers/pgpm-ltree-helpers.control new file mode 100644 index 00000000..ac6d5743 --- /dev/null +++ b/packages/ltree-helpers/pgpm-ltree-helpers.control @@ -0,0 +1,7 @@ +# pgpm-ltree-helpers extension +comment = 'pgpm-ltree-helpers extension' +default_version = '0.21.0' +module_pathname = '$libdir/pgpm-ltree-helpers' +requires = 'plpgsql,ltree,pgpm-verify' +relocatable = false +superuser = false diff --git a/packages/ltree-helpers/pgpm.plan b/packages/ltree-helpers/pgpm.plan new file mode 100644 index 00000000..650d8056 --- /dev/null +++ b/packages/ltree-helpers/pgpm.plan @@ -0,0 +1,8 @@ +%syntax-version=1.0.0 +%project=pgpm-ltree-helpers +%uri=pgpm-ltree-helpers + +schemas/ltree_helpers/schema 2026-05-01T06:00:00Z devin # add schemas/ltree_helpers/schema +schemas/ltree_helpers/procedures/to_path [schemas/ltree_helpers/schema] 2026-05-01T06:00:00Z devin # add schemas/ltree_helpers/procedures/to_path +schemas/ltree_helpers/procedures/to_slash [schemas/ltree_helpers/schema] 2026-05-01T06:00:00Z devin # add schemas/ltree_helpers/procedures/to_slash +schemas/ltree_helpers/procedures/to_query [schemas/ltree_helpers/schema] 2026-05-01T06:00:00Z devin # add schemas/ltree_helpers/procedures/to_query diff --git a/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_path.sql b/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_path.sql new file mode 100644 index 00000000..9cf9d89f --- /dev/null +++ b/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_path.sql @@ -0,0 +1,7 @@ +-- Revert schemas/ltree_helpers/procedures/to_path from pg + +BEGIN; + +DROP FUNCTION ltree_helpers.to_path; + +COMMIT; diff --git a/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_query.sql b/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_query.sql new file mode 100644 index 00000000..cc81a9df --- /dev/null +++ b/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_query.sql @@ -0,0 +1,7 @@ +-- Revert schemas/ltree_helpers/procedures/to_query from pg + +BEGIN; + +DROP FUNCTION ltree_helpers.to_query; + +COMMIT; diff --git a/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_slash.sql b/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_slash.sql new file mode 100644 index 00000000..d2fee251 --- /dev/null +++ b/packages/ltree-helpers/revert/schemas/ltree_helpers/procedures/to_slash.sql @@ -0,0 +1,7 @@ +-- Revert schemas/ltree_helpers/procedures/to_slash from pg + +BEGIN; + +DROP FUNCTION ltree_helpers.to_slash; + +COMMIT; diff --git a/packages/ltree-helpers/revert/schemas/ltree_helpers/schema.sql b/packages/ltree-helpers/revert/schemas/ltree_helpers/schema.sql new file mode 100644 index 00000000..2f76d553 --- /dev/null +++ b/packages/ltree-helpers/revert/schemas/ltree_helpers/schema.sql @@ -0,0 +1,7 @@ +-- Revert schemas/ltree_helpers/schema from pg + +BEGIN; + +DROP SCHEMA ltree_helpers CASCADE; + +COMMIT; diff --git a/packages/ltree-helpers/sql/pgpm-ltree-helpers--0.21.0.sql b/packages/ltree-helpers/sql/pgpm-ltree-helpers--0.21.0.sql new file mode 100644 index 00000000..b1265b3e --- /dev/null +++ b/packages/ltree-helpers/sql/pgpm-ltree-helpers--0.21.0.sql @@ -0,0 +1,22 @@ +\echo Use "CREATE EXTENSION pgpm-ltree-helpers" to load this file. \quit +CREATE SCHEMA ltree_helpers; + +GRANT USAGE ON SCHEMA ltree_helpers TO authenticated, anonymous; + +ALTER DEFAULT PRIVILEGES IN SCHEMA ltree_helpers + GRANT EXECUTE ON FUNCTIONS TO authenticated; + +CREATE FUNCTION ltree_helpers.to_path(slash_path text) RETURNS ltree AS $EOFCODE$ + SELECT replace(ltrim(slash_path, '/'), '/', '.')::ltree; +$EOFCODE$ LANGUAGE sql IMMUTABLE STRICT; + +CREATE FUNCTION ltree_helpers.to_slash(lpath ltree) RETURNS text AS $EOFCODE$ + SELECT '/' || replace(lpath::text, '.', '/'); +$EOFCODE$ LANGUAGE sql IMMUTABLE STRICT; + +CREATE FUNCTION ltree_helpers.to_query(glob text) RETURNS lquery AS $EOFCODE$ + SELECT replace( + replace(ltrim(glob, '/'), '**', '*{1,}'), + '/', '.' + )::lquery; +$EOFCODE$ LANGUAGE sql IMMUTABLE STRICT; \ No newline at end of file diff --git a/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_path.sql b/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_path.sql new file mode 100644 index 00000000..28a480f4 --- /dev/null +++ b/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_path.sql @@ -0,0 +1,7 @@ +-- Verify schemas/ltree_helpers/procedures/to_path on pg + +BEGIN; + +SELECT verify_function ('ltree_helpers.to_path'); + +ROLLBACK; diff --git a/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_query.sql b/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_query.sql new file mode 100644 index 00000000..ad2e1305 --- /dev/null +++ b/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_query.sql @@ -0,0 +1,7 @@ +-- Verify schemas/ltree_helpers/procedures/to_query on pg + +BEGIN; + +SELECT verify_function ('ltree_helpers.to_query'); + +ROLLBACK; diff --git a/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_slash.sql b/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_slash.sql new file mode 100644 index 00000000..2957c701 --- /dev/null +++ b/packages/ltree-helpers/verify/schemas/ltree_helpers/procedures/to_slash.sql @@ -0,0 +1,7 @@ +-- Verify schemas/ltree_helpers/procedures/to_slash on pg + +BEGIN; + +SELECT verify_function ('ltree_helpers.to_slash'); + +ROLLBACK; diff --git a/packages/ltree-helpers/verify/schemas/ltree_helpers/schema.sql b/packages/ltree-helpers/verify/schemas/ltree_helpers/schema.sql new file mode 100644 index 00000000..9f8dbe33 --- /dev/null +++ b/packages/ltree-helpers/verify/schemas/ltree_helpers/schema.sql @@ -0,0 +1,7 @@ +-- Verify schemas/ltree_helpers/schema on pg + +BEGIN; + +SELECT verify_schema ('ltree_helpers'); + +ROLLBACK; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca756ad1..305ea4af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,16 @@ importers: specifier: ^4.16.6 version: 4.16.6(@dataplan/json@1.0.0(grafast@1.0.0(graphql@16.13.0)))(@dataplan/pg@1.0.0(@dataplan/json@1.0.0(grafast@1.0.0(graphql@16.13.0)))(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(pg-sql2@5.0.0)(pg@8.20.0))(@types/node@22.19.17)(grafserv@1.0.0(@types/node@22.19.17)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.0))(graphile-build@5.0.0(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0))(pg-sql2@5.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tamedevil@0.1.0)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.0) + packages/ltree-helpers: + dependencies: + '@pgpm/verify': + specifier: workspace:* + version: link:../verify + devDependencies: + pgpm: + specifier: ^4.16.6 + version: 4.16.6(@dataplan/json@1.0.0(grafast@1.0.0(graphql@16.13.0)))(@dataplan/pg@1.0.0(@dataplan/json@1.0.0(grafast@1.0.0(graphql@16.13.0)))(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(pg-sql2@5.0.0)(pg@8.20.0))(@types/node@22.19.17)(grafserv@1.0.0(@types/node@22.19.17)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.0))(graphile-build@5.0.0(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0))(pg-sql2@5.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tamedevil@0.1.0)(use-sync-external-store@1.6.0(react@19.2.5))(ws@8.20.0) + packages/measurements: dependencies: '@pgpm/verify':