diff --git a/.github/workflows/release-eql.yml b/.github/workflows/release-eql.yml index 7e74d259..508652ff 100644 --- a/.github/workflows/release-eql.yml +++ b/.github/workflows/release-eql.yml @@ -66,3 +66,51 @@ jobs: --header "Content-Type: application/json" \ --header "Authorization: ${{ secrets.MULTITUDES_ACCESS_TOKEN }}" \ --data '{"commitSha": "${{ github.sha }}", "environmentName":"production"}' + + publish-docs: + runs-on: ubuntu-latest + name: Build and Publish Documentation + if: ${{ github.event_name != 'release' || contains(github.event.release.tag_name, 'eql') }} + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: jdx/mise-action@v2 + with: + version: 2025.1.6 # [default: latest] mise version to install + install: true # [default: true] run `mise install` + cache: true # [default: true] cache mise using GitHub's cache + + - name: Install Doxygen + run: | + sudo apt-get update + sudo apt-get install -y doxygen + + - name: Validate documentation + run: | + mise run docs:validate + + - name: Generate documentation + run: | + mise run docs:generate + + - name: Package documentation + run: | + mise run docs:package ${{ github.event.release.tag_name }} + + - name: Upload documentation artifacts + uses: actions/upload-artifact@v4 + with: + name: eql-docs + path: | + release/eql-docs-*.zip + release/eql-docs-*.tar.gz + + - name: Publish documentation to release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + release/eql-docs-*.zip + release/eql-docs-*.tar.gz diff --git a/.github/workflows/test-eql.yml b/.github/workflows/test-eql.yml index 1d34d5ac..2fd301ea 100644 --- a/.github/workflows/test-eql.yml +++ b/.github/workflows/test-eql.yml @@ -26,9 +26,31 @@ defaults: shell: bash -l {0} jobs: + validate-docs: + name: "Validate SQL Documentation" + runs-on: ubuntu-latest-m + + steps: + - uses: actions/checkout@v4 + + - uses: jdx/mise-action@v2 + with: + version: 2025.1.6 + install: true + cache: true + + - name: Setup database + run: | + mise run postgres:up --extra-args "--detach --wait" + + - name: Validate SQL documentation + run: | + mise run docs:validate + test: name: "Test EQL SQL components" runs-on: ubuntu-latest-m + needs: validate-docs strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index 3ba74c4b..7cbf79ae 100644 --- a/.gitignore +++ b/.gitignore @@ -199,6 +199,9 @@ cipherstash-proxy.toml # build artifacts release/ +# Generated documentation +docs/api/ + # jupyter notebook diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d45e9c1d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,148 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +This project uses `mise` for task management. Common commands: + +- `mise run build` (alias: `mise r b`) - Build SQL into single release file +- `mise run test` (alias: `mise r test`) - Build, reset and run tests +- `mise run postgres:up` - Start PostgreSQL container +- `mise run postgres:down` - Stop PostgreSQL containers +- `mise run reset` - Reset database state +- `mise run clean` (alias: `mise r k`) - Clean release files +- `mise run docs:generate` - Generate API documentation (requires doxygen) +- `mise run docs:validate` - Validate documentation coverage and tags + +### Testing +- Run all tests: `mise run test` +- Run specific test: `mise run test --test ` +- Run tests against specific PostgreSQL version: `mise run test --postgres 14|15|16|17` +- Tests are located in `*_test.sql` files alongside source code + +### Build System +- Dependencies are resolved using `-- REQUIRE:` comments in SQL files +- Build outputs to `release/` directory: + - `cipherstash-encrypt.sql` - Main installer + - `cipherstash-encrypt-supabase.sql` - Supabase-compatible installer + - `cipherstash-encrypt-uninstall.sql` - Uninstaller + +## Project Architecture + +This is the **Encrypt Query Language (EQL)** - a PostgreSQL extension for searchable encryption. Key architectural components: + +### Core Structure +- **Schema**: All EQL functions/types are in `eql_v2` PostgreSQL schema +- **Main Type**: `eql_v2_encrypted` - composite type for encrypted columns (stored as JSONB) +- **Configuration**: `eql_v2_configuration` table tracks encryption configs +- **Index Types**: Various encrypted index types (blake3, hmac_256, bloom_filter, ore variants) + +### Directory Structure +- `src/` - Modular SQL components with dependency management +- `src/encrypted/` - Core encrypted column type implementation +- `src/operators/` - SQL operators for encrypted data comparisons +- `src/config/` - Configuration management functions +- `src/blake3/`, `src/hmac_256/`, `src/bloom_filter/`, `src/ore_*` - Index implementations +- `tasks/` - mise task scripts +- `tests/` - Test files (PostgreSQL 14-17 support) +- `release/` - Generated SQL installation files + +### Key Concepts +- **Dependency System**: SQL files declare dependencies via `-- REQUIRE:` comments +- **Encrypted Data**: Stored as JSONB payloads with metadata +- **Index Terms**: Transient types for search operations (blake3, hmac_256, etc.) +- **Operators**: Support comparisons between encrypted and plain JSONB data +- **CipherStash Proxy**: Required for encryption/decryption operations + +### Testing Infrastructure +- Tests run against PostgreSQL 14, 15, 16, 17 using Docker containers +- Container configuration in `tests/docker-compose.yml` +- Test helpers in `tests/test_helpers.sql` +- Database connection: `localhost:7432` (cipherstash/password) +- **Rust/SQLx Tests**: Modern test framework in `tests/sqlx/` (see README there) + +## Project Learning & Retrospectives + +Valuable lessons and insights from completed work: + +- **SQLx Test Migration (2025-10-24)**: See `docs/retrospectives/2025-10-24-sqlx-migration-retrospective.md` + - Migrated 40 SQL assertions to Rust/SQLx (100% coverage) + - Key insights: Blake3 vs HMAC differences, batch-review pattern effectiveness, coverage metric definitions + - Lessons: TDD catches setup issues, infrastructure investment pays off, code review after each batch prevents compound errors + +## Documentation Standards + +### Doxygen Comments + +All SQL functions and types must be documented using Doxygen-style comments: + +- **Comment Style**: Use `--!` prefix for Doxygen comments (not `--`) +- **Required Tags**: + - `@brief` - Short description (required for all functions/files) + - `@param` - Parameter description (required for functions with parameters) + - `@return` - Return value description (required for functions with non-void returns) +- **Optional Tags**: + - `@throws` - Exception conditions + - `@note` - Important notes or caveats + - `@warning` - Warning messages (e.g., for DDL-executing functions) + - `@see` - Cross-references to related functions + - `@example` - Usage examples + - `@internal` - Mark internal/private functions + - `@file` - File-level documentation + +### Documentation Example + +```sql +--! @brief Create encrypted index configuration +--! +--! Initializes a new encrypted index configuration for a table column. +--! The configuration tracks encryption settings and index types. +--! +--! @param p_table_name text Table name (schema-qualified) +--! @param p_column_name text Column name to encrypt +--! @param p_index_type text Type of encrypted index (blake3, hmac_256, etc.) +--! +--! @return uuid Configuration ID for the created index +--! +--! @throws unique_violation If configuration already exists for this column +--! +--! @note This function executes DDL and modifies database schema +--! @see eql_v2.activate_encrypted_index +--! +--! @example +--! -- Create blake3 index configuration +--! SELECT eql_v2.create_encrypted_index( +--! 'public.users', +--! 'email', +--! 'blake3' +--! ); +CREATE FUNCTION eql_v2.create_encrypted_index(...) +``` + +### Validation Tools + +Verify documentation quality: + +```bash +# Using mise (recommended - validates coverage and tags) +mise run docs:validate + +# Or run individual scripts directly +mise run docs:validate:coverage # Check 100% coverage +mise run docs:validate:required-tags # Verify @brief, @param, @return tags +mise run docs:validate:documented-sql # Validate SQL syntax (requires database) +``` + +### Template Files + +Template files (e.g., `version.template`) must be documented. The Doxygen comments are automatically included in generated files during build. + +## Development Notes + +- SQL files are modular - put operator wrappers in `operators.sql`, implementation in `functions.sql` +- All SQL files must have `-- REQUIRE:` dependency declarations +- Test files end with `_test.sql` and live alongside source files +- Build system uses `tsort` to resolve dependency order +- Supabase build excludes operator classes (not supported) +- **Documentation**: All functions/types must have Doxygen comments (see Documentation Standards above) \ No newline at end of file diff --git a/Doxyfile b/Doxyfile new file mode 100644 index 00000000..1b00e764 --- /dev/null +++ b/Doxyfile @@ -0,0 +1,95 @@ +# Doxyfile for Encrypt Query Language (EQL) +# PostgreSQL extension for searchable encryption + +#--------------------------------------------------------------------------- +# Project Settings +#--------------------------------------------------------------------------- + +PROJECT_NAME = "Encrypt Query Language (EQL)" +PROJECT_NUMBER = "2.x" +PROJECT_BRIEF = "PostgreSQL extension for searchable encryption" + +OUTPUT_DIRECTORY = docs/api +CREATE_SUBDIRS = NO + +#--------------------------------------------------------------------------- +# Build Settings +#--------------------------------------------------------------------------- + +GENERATE_HTML = YES +GENERATE_LATEX = NO +GENERATE_XML = NO +GENERATE_MAN = NO + +HTML_OUTPUT = html +HTML_FILE_EXTENSION = .html +HTML_DYNAMIC_SECTIONS = YES + +#--------------------------------------------------------------------------- +# Input Settings +#--------------------------------------------------------------------------- + +INPUT = src/ +FILE_PATTERNS = *.sql *.template +RECURSIVE = YES +EXCLUDE_PATTERNS = *_test.sql + +# Treat SQL files as C++ for parsing +EXTENSION_MAPPING = sql=C++ template=C++ + +# CRITICAL: Input filter to convert SQL comments (--!) to C++ style (//!) +# This is REQUIRED for Doxygen to recognize SQL comments +INPUT_FILTER = "tasks/docs/doxygen-filter.sh" +FILTER_SOURCE_FILES = YES + +#--------------------------------------------------------------------------- +# Extraction Settings +#--------------------------------------------------------------------------- + +EXTRACT_ALL = YES +EXTRACT_PRIVATE = YES +EXTRACT_STATIC = YES + +HIDE_UNDOC_MEMBERS = NO +HIDE_UNDOC_CLASSES = NO + +SHOW_FILES = YES +SHOW_NAMESPACES = YES + +#--------------------------------------------------------------------------- +# Documentation Settings +#--------------------------------------------------------------------------- + +JAVADOC_AUTOBRIEF = YES +OPTIMIZE_OUTPUT_FOR_C = YES + +#--------------------------------------------------------------------------- +# Warning Settings +#--------------------------------------------------------------------------- + +QUIET = NO +WARNINGS = YES +WARN_IF_UNDOCUMENTED = NO +WARN_IF_DOC_ERROR = YES +WARN_NO_PARAMDOC = NO + +#--------------------------------------------------------------------------- +# Source Browsing +#--------------------------------------------------------------------------- + +SOURCE_BROWSER = YES +INLINE_SOURCES = NO +REFERENCED_BY_RELATION = YES +REFERENCES_RELATION = YES + +#--------------------------------------------------------------------------- +# Alphabetical Index +#--------------------------------------------------------------------------- + +ALPHABETICAL_INDEX = YES + +#--------------------------------------------------------------------------- +# Search Engine +#--------------------------------------------------------------------------- + +SEARCHENGINE = YES diff --git a/README.md b/README.md index 262d32cc..abedc751 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Store encrypted data alongside your existing data: - [Getting started](#getting-started) - [Enable encrypted columns](#enable-encrypted-columns) - [Encrypt configuration](#encrypt-configuration) +- [Documentation](#documentation) - [CipherStash integrations using EQL](#cipherstash-integrations-using-eql) - [Versioning](#versioning) - [Upgrading](#upgrading) @@ -204,6 +205,64 @@ In order to enable searchable encryption, you will need to configure your Cipher - If you are using [CipherStash Proxy](https://github.com/cipherstash/proxy), see [this guide](docs/tutorials/proxy-configuration.md). - If you are using [Protect.js](https://github.com/cipherstash/protectjs), use the [Protect.js schema](https://github.com/cipherstash/protectjs/blob/main/docs/reference/schema.md). +## Documentation + +### API Documentation + +All EQL functions and types are fully documented with Doxygen-style comments in the source code. + +**Install Doxygen** (required for documentation generation): + +```bash +# macOS +brew install doxygen + +# Ubuntu/Debian +apt-get install doxygen + +# Other platforms: https://www.doxygen.nl/download.html +``` + +**Generate API documentation:** + +```bash +# Using mise +mise run docs:generate + +# Or directly with doxygen +doxygen Doxyfile +``` + +The generated HTML documentation will be available at `docs/api/html/index.html`. + +### Documentation Standards + +All SQL functions, types, and operators include: +- **@brief** - Short description of purpose +- **@param** - Parameter descriptions with types +- **@return** - Return value description and type +- **@example** - Usage examples +- **@throws** - Exception conditions +- **@note** - Important notes and caveats + +For contribution guidelines, see [CLAUDE.md](./CLAUDE.md). + +### Validation Tools + +Verify documentation quality using these scripts: + +```bash +# Using mise (validates coverage and tags) +mise run docs:validate + +# Or run individual checks +./tasks/check-doc-coverage.sh # Check 100% coverage +./tasks/validate-required-tags.sh # Validate @brief, @param, @return +./tasks/validate-documented-sql.sh # Validate SQL syntax +``` + +Documentation validation runs automatically in CI for all pull requests. + ## CipherStash integrations using EQL These frameworks use EQL to enable searchable encryption functionality in PostgreSQL. diff --git a/src/common.sql b/src/common.sql index f47d917e..6a7c1823 100644 --- a/src/common.sql +++ b/src/common.sql @@ -1,9 +1,28 @@ -- AUTOMATICALLY GENERATED FILE -- REQUIRE: src/schema.sql --- Constant time comparison of 2 bytea values +--! @file common.sql +--! @brief Common utility functions +--! +--! Provides general-purpose utility functions used across EQL: +--! - Constant-time bytea comparison for security +--! - JSONB to bytea array conversion +--! - Logging helpers for debugging and testing +--! @brief Constant-time comparison of bytea values +--! @internal +--! +--! Compares two bytea values in constant time to prevent timing attacks. +--! Always checks all bytes even after finding differences, maintaining +--! consistent execution time regardless of where differences occur. +--! +--! @param a bytea First value to compare +--! @param b bytea Second value to compare +--! @return boolean True if values are equal +--! +--! @note Returns false immediately if lengths differ (length is not secret) +--! @note Used for secure comparison of cryptographic values CREATE FUNCTION eql_v2.bytea_eq(a bytea, b bytea) RETURNS boolean AS $$ DECLARE result boolean; @@ -27,7 +46,18 @@ BEGIN END; $$ LANGUAGE plpgsql; --- Casts a jsonb array of hex-encoded strings to an array of bytea. + +--! @brief Convert JSONB hex array to bytea array +--! @internal +--! +--! Converts a JSONB array of hex-encoded strings into a PostgreSQL bytea array. +--! Used for deserializing binary data (like ORE terms) from JSONB storage. +--! +--! @param val jsonb JSONB array of hex-encoded strings +--! @return bytea[] Array of decoded binary values +--! +--! @note Returns NULL if input is JSON null +--! @note Each array element is hex-decoded to bytea CREATE FUNCTION eql_v2.jsonb_array_to_bytea_array(val jsonb) RETURNS bytea[] AS $$ DECLARE @@ -46,10 +76,15 @@ END; $$ LANGUAGE plpgsql; - --- --- Convenience function to log a message --- +--! @brief Log message for debugging +--! +--! Convenience function to emit log messages during testing and debugging. +--! Uses RAISE NOTICE to output messages to PostgreSQL logs. +--! +--! @param s text Message to log +--! +--! @note Primarily used in tests and development +--! @see eql_v2.log(text, text) for contextual logging CREATE FUNCTION eql_v2.log(s text) RETURNS void AS $$ @@ -59,9 +94,16 @@ END; $$ LANGUAGE plpgsql; --- --- Convenience function to describe a test --- +--! @brief Log message with context +--! +--! Overload of log function that includes context label for better +--! log organization during testing. +--! +--! @param ctx text Context label (e.g., test name, module name) +--! @param s text Message to log +--! +--! @note Format: "[LOG] {ctx} {message}" +--! @see eql_v2.log(text) CREATE FUNCTION eql_v2.log(ctx text, s text) RETURNS void AS $$ diff --git a/src/config/constraints.sql b/src/config/constraints.sql index 1b44b4d7..378984b4 100644 --- a/src/config/constraints.sql +++ b/src/config/constraints.sql @@ -1,10 +1,26 @@ -- REQUIRE: src/config/types.sql --- --- Extracts index keys/names from configuration json --- --- Used by the eql_v2.config_check_indexes as part of the configuration_data_v2 constraint --- +--! @file config/constraints.sql +--! @brief Configuration validation functions and constraints +--! +--! Provides CHECK constraint functions to validate encryption configuration structure. +--! Ensures configurations have required fields (version, tables) and valid values +--! for index types and cast types before being stored. +--! +--! @see config/tables.sql where constraints are applied + + +--! @brief Extract index type names from configuration +--! @internal +--! +--! Helper function that extracts all index type names from the configuration's +--! 'indexes' sections across all tables and columns. +--! +--! @param val jsonb Configuration data to extract from +--! @return SETOF text Index type names (e.g., 'match', 'ore', 'unique', 'ste_vec') +--! +--! @note Used by config_check_indexes for validation +--! @see eql_v2.config_check_indexes CREATE FUNCTION eql_v2.config_get_indexes(val jsonb) RETURNS SETOF text LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE @@ -12,11 +28,19 @@ BEGIN ATOMIC SELECT jsonb_object_keys(jsonb_path_query(val,'$.tables.*.*.indexes')); END; --- --- _cs_check_config_get_indexes returns true if the table configuration only includes valid index types --- --- Used by the cs_configuration_data_v2_check constraint --- + +--! @brief Validate index types in configuration +--! @internal +--! +--! Checks that all index types specified in the configuration are valid. +--! Valid index types are: match, ore, unique, ste_vec. +--! +--! @param val jsonb Configuration data to validate +--! @return boolean True if all index types are valid +--! @throws Exception if any invalid index type found +--! +--! @note Used in CHECK constraint on eql_v2_configuration table +--! @see eql_v2.config_get_indexes CREATE FUNCTION eql_v2.config_check_indexes(val jsonb) RETURNS BOOLEAN IMMUTABLE STRICT PARALLEL SAFE @@ -34,7 +58,19 @@ AS $$ $$ LANGUAGE plpgsql; - +--! @brief Validate cast types in configuration +--! @internal +--! +--! Checks that all 'cast_as' types specified in the configuration are valid. +--! Valid cast types are: text, int, small_int, big_int, real, double, boolean, date, jsonb. +--! +--! @param val jsonb Configuration data to validate +--! @return boolean True if all cast types are valid or no cast types specified +--! @throws Exception if any invalid cast type found +--! +--! @note Used in CHECK constraint on eql_v2_configuration table +--! @note Empty configurations (no cast_as fields) are valid +--! @note Cast type names are EQL's internal representations, not PostgreSQL native types CREATE FUNCTION eql_v2.config_check_cast(val jsonb) RETURNS BOOLEAN AS $$ @@ -52,9 +88,18 @@ AS $$ END; $$ LANGUAGE plpgsql; --- --- Should include a tables field --- Tables should not be empty + +--! @brief Validate tables field presence +--! @internal +--! +--! Ensures the configuration has a 'tables' field, which is required +--! to specify which database tables contain encrypted columns. +--! +--! @param val jsonb Configuration data to validate +--! @return boolean True if 'tables' field exists +--! @throws Exception if 'tables' field is missing +--! +--! @note Used in CHECK constraint on eql_v2_configuration table CREATE FUNCTION eql_v2.config_check_tables(val jsonb) RETURNS boolean AS $$ @@ -66,7 +111,18 @@ AS $$ END; $$ LANGUAGE plpgsql; --- Should include a version field + +--! @brief Validate version field presence +--! @internal +--! +--! Ensures the configuration has a 'v' (version) field, which tracks +--! the configuration format version. +--! +--! @param val jsonb Configuration data to validate +--! @return boolean True if 'v' field exists +--! @throws Exception if 'v' field is missing +--! +--! @note Used in CHECK constraint on eql_v2_configuration table CREATE FUNCTION eql_v2.config_check_version(val jsonb) RETURNS boolean AS $$ @@ -79,8 +135,24 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Drop existing data validation constraint if present +--! @note Allows constraint to be recreated during upgrades ALTER TABLE public.eql_v2_configuration DROP CONSTRAINT IF EXISTS eql_v2_configuration_data_check; + +--! @brief Comprehensive configuration data validation +--! +--! CHECK constraint that validates all aspects of configuration data: +--! - Version field presence +--! - Tables field presence +--! - Valid cast_as types +--! - Valid index types +--! +--! @note Combines all config_check_* validation functions +--! @see eql_v2.config_check_version +--! @see eql_v2.config_check_tables +--! @see eql_v2.config_check_cast +--! @see eql_v2.config_check_indexes ALTER TABLE public.eql_v2_configuration ADD CONSTRAINT eql_v2_configuration_data_check CHECK ( eql_v2.config_check_version(data) AND diff --git a/src/config/indexes.sql b/src/config/indexes.sql index 570a7291..7d1d683b 100644 --- a/src/config/indexes.sql +++ b/src/config/indexes.sql @@ -2,10 +2,27 @@ -- REQUIRE: src/config/tables.sql --- --- Define partial indexes to ensure that there is only one active, pending and encrypting config at a time --- +--! @file config/indexes.sql +--! @brief Configuration state uniqueness indexes +--! +--! Creates partial unique indexes to enforce that only one configuration +--! can be in 'active', 'pending', or 'encrypting' state at any time. +--! Multiple 'inactive' configurations are allowed. +--! +--! @note Uses partial indexes (WHERE clauses) for efficiency +--! @note Prevents conflicting configurations from being active simultaneously +--! @see config/types.sql for state definitions + + +--! @brief Unique active configuration constraint +--! @note Only one configuration can be 'active' at once CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'active'; + +--! @brief Unique pending configuration constraint +--! @note Only one configuration can be 'pending' at once CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'pending'; + +--! @brief Unique encrypting configuration constraint +--! @note Only one configuration can be 'encrypting' at once CREATE UNIQUE INDEX ON public.eql_v2_configuration (state) WHERE state = 'encrypting'; diff --git a/src/config/tables.sql b/src/config/tables.sql index 8fded8c5..72379013 100644 --- a/src/config/tables.sql +++ b/src/config/tables.sql @@ -1,9 +1,27 @@ -- REQUIRE: src/config/types.sql --- --- --- CREATE the eql_v2_configuration TABLE --- +--! @file config/tables.sql +--! @brief Encryption configuration storage table +--! +--! Defines the main table for storing EQL v2 encryption configurations. +--! Each row represents a configuration specifying which tables/columns to encrypt +--! and what index types to use. Configurations progress through lifecycle states. +--! +--! @see config/types.sql for state ENUM definition +--! @see config/indexes.sql for state uniqueness constraints +--! @see config/constraints.sql for data validation + + +--! @brief Encryption configuration table +--! +--! Stores encryption configurations with their state and metadata. +--! The 'data' JSONB column contains the full configuration structure including +--! table/column mappings, index types, and casting rules. +--! +--! @note Only one configuration can be 'active', 'pending', or 'encrypting' at once +--! @note 'id' is auto-generated identity column +--! @note 'state' defaults to 'pending' for new configurations +--! @note 'data' validated by CHECK constraint (see config/constraints.sql) CREATE TABLE IF NOT EXISTS public.eql_v2_configuration ( id bigint GENERATED ALWAYS AS IDENTITY, diff --git a/src/config/types.sql b/src/config/types.sql index a0d5cc40..3e994334 100644 --- a/src/config/types.sql +++ b/src/config/types.sql @@ -1,21 +1,23 @@ --- --- cs_configuration_data_v2 is a jsonb column that stores the actual configuration --- --- For some reason CREATE DOMAIN and CREATE TYPE do not support IF NOT EXISTS --- Types cannot be dropped if used by a table, and we never drop the configuration table --- DOMAIN constraints are added separately and not tied to DOMAIN creation --- --- DO $$ --- BEGIN --- IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'configuration_data') THEN --- CREATE DOMAIN eql_v2.configuration_data AS JSONB; --- END IF; --- END --- $$; +--! @file config/types.sql +--! @brief Configuration state type definition +--! +--! Defines the ENUM type for tracking encryption configuration lifecycle states. +--! The configuration table uses this type to manage transitions between states +--! during setup, activation, and encryption operations. +--! +--! @note CREATE TYPE does not support IF NOT EXISTS, so wrapped in DO block +--! @note Configuration data stored as JSONB directly, not as DOMAIN +--! @see config/tables.sql --- --- cs_configuration_state_v2 is an ENUM that defines the valid configuration states --- -- + +--! @brief Configuration lifecycle state +--! +--! Defines valid states for encryption configurations in the eql_v2_configuration table. +--! Configurations transition through these states during setup and activation. +--! +--! @note Only one configuration can be in 'active', 'pending', or 'encrypting' state at once +--! @see config/indexes.sql for uniqueness enforcement +--! @see config/tables.sql for usage in eql_v2_configuration table DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eql_v2_configuration_state') THEN diff --git a/src/crypto.sql b/src/crypto.sql index f4364d1d..8e9482ef 100644 --- a/src/crypto.sql +++ b/src/crypto.sql @@ -1,4 +1,15 @@ -- REQUIRE: src/schema.sql +--! @file crypto.sql +--! @brief PostgreSQL pgcrypto extension enablement +--! +--! Enables the pgcrypto extension which provides cryptographic functions +--! used by EQL for hashing and other cryptographic operations. +--! +--! @note pgcrypto provides functions like digest(), hmac(), gen_random_bytes() +--! @note IF NOT EXISTS prevents errors if extension already enabled + +--! @brief Enable pgcrypto extension +--! @note Provides cryptographic functions for hashing and random number generation CREATE EXTENSION IF NOT EXISTS pgcrypto; diff --git a/src/encrypted/aggregates.sql b/src/encrypted/aggregates.sql index d6b896bc..0f1d7657 100644 --- a/src/encrypted/aggregates.sql +++ b/src/encrypted/aggregates.sql @@ -4,6 +4,17 @@ -- Aggregate functions for ORE +--! @brief State transition function for min aggregate +--! @internal +--! +--! Returns the smaller of two encrypted values for use in MIN aggregate. +--! Comparison uses ORE index terms without decryption. +--! +--! @param a eql_v2_encrypted First encrypted value +--! @param b eql_v2_encrypted Second encrypted value +--! @return eql_v2_encrypted The smaller of the two values +--! +--! @see eql_v2.min(eql_v2_encrypted) CREATE FUNCTION eql_v2.min(a eql_v2_encrypted, b eql_v2_encrypted) RETURNS eql_v2_encrypted STRICT @@ -18,6 +29,22 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Find minimum encrypted value in a group +--! +--! Aggregate function that returns the minimum encrypted value in a group +--! using ORE index term comparisons without decryption. +--! +--! @param input eql_v2_encrypted Encrypted values to aggregate +--! @return eql_v2_encrypted Minimum value in the group +--! +--! @example +--! -- Find minimum age per department +--! SELECT department, eql_v2.min(encrypted_age) +--! FROM employees +--! GROUP BY department; +--! +--! @note Requires 'ore' index configuration on the column +--! @see eql_v2.min(eql_v2_encrypted, eql_v2_encrypted) CREATE AGGREGATE eql_v2.min(eql_v2_encrypted) ( sfunc = eql_v2.min, @@ -25,6 +52,17 @@ CREATE AGGREGATE eql_v2.min(eql_v2_encrypted) ); +--! @brief State transition function for max aggregate +--! @internal +--! +--! Returns the larger of two encrypted values for use in MAX aggregate. +--! Comparison uses ORE index terms without decryption. +--! +--! @param a eql_v2_encrypted First encrypted value +--! @param b eql_v2_encrypted Second encrypted value +--! @return eql_v2_encrypted The larger of the two values +--! +--! @see eql_v2.max(eql_v2_encrypted) CREATE FUNCTION eql_v2.max(a eql_v2_encrypted, b eql_v2_encrypted) RETURNS eql_v2_encrypted STRICT @@ -39,6 +77,22 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Find maximum encrypted value in a group +--! +--! Aggregate function that returns the maximum encrypted value in a group +--! using ORE index term comparisons without decryption. +--! +--! @param input eql_v2_encrypted Encrypted values to aggregate +--! @return eql_v2_encrypted Maximum value in the group +--! +--! @example +--! -- Find maximum salary per department +--! SELECT department, eql_v2.max(encrypted_salary) +--! FROM employees +--! GROUP BY department; +--! +--! @note Requires 'ore' index configuration on the column +--! @see eql_v2.max(eql_v2_encrypted, eql_v2_encrypted) CREATE AGGREGATE eql_v2.max(eql_v2_encrypted) ( sfunc = eql_v2.max, diff --git a/src/encrypted/casts.sql b/src/encrypted/casts.sql index 7d6eea3b..2dbfff5e 100644 --- a/src/encrypted/casts.sql +++ b/src/encrypted/casts.sql @@ -2,10 +2,16 @@ -- REQUIRE: src/encrypted/types.sql --- --- Convert jsonb to eql_v2.encrypted --- - +--! @brief Convert JSONB to encrypted type +--! +--! Wraps a JSONB encrypted payload into the eql_v2_encrypted composite type. +--! Used internally for type conversions and operator implementations. +--! +--! @param data jsonb JSONB encrypted payload with structure: {"c": "...", "i": {...}, "k": "...", "v": "2"} +--! @return eql_v2_encrypted Encrypted value wrapped in composite type +--! +--! @note This is primarily used for implicit casts in operator expressions +--! @see eql_v2.to_jsonb CREATE FUNCTION eql_v2.to_encrypted(data jsonb) RETURNS public.eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE @@ -20,18 +26,26 @@ END; $$ LANGUAGE plpgsql; --- --- Cast jsonb to eql_v2.encrypted --- - +--! @brief Implicit cast from JSONB to encrypted type +--! +--! Enables PostgreSQL to automatically convert JSONB values to eql_v2_encrypted +--! in assignment contexts and comparison operations. +--! +--! @see eql_v2.to_encrypted(jsonb) CREATE CAST (jsonb AS public.eql_v2_encrypted) WITH FUNCTION eql_v2.to_encrypted(jsonb) AS ASSIGNMENT; --- --- Convert text to eql_v2.encrypted --- - +--! @brief Convert text to encrypted type +--! +--! Parses a text representation of encrypted JSONB payload and wraps it +--! in the eql_v2_encrypted composite type. +--! +--! @param data text Text representation of JSONB encrypted payload +--! @return eql_v2_encrypted Encrypted value wrapped in composite type +--! +--! @note Delegates to eql_v2.to_encrypted(jsonb) after parsing text as JSON +--! @see eql_v2.to_encrypted(jsonb) CREATE FUNCTION eql_v2.to_encrypted(data text) RETURNS public.eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE @@ -46,19 +60,27 @@ END; $$ LANGUAGE plpgsql; --- --- Cast text to eql_v2.encrypted --- - +--! @brief Implicit cast from text to encrypted type +--! +--! Enables PostgreSQL to automatically convert text JSON strings to eql_v2_encrypted +--! in assignment contexts. +--! +--! @see eql_v2.to_encrypted(text) CREATE CAST (text AS public.eql_v2_encrypted) WITH FUNCTION eql_v2.to_encrypted(text) AS ASSIGNMENT; --- --- Convert eql_v2.encrypted to jsonb --- - +--! @brief Convert encrypted type to JSONB +--! +--! Extracts the underlying JSONB payload from an eql_v2_encrypted composite type. +--! Useful for debugging or when raw encrypted payload access is needed. +--! +--! @param e eql_v2_encrypted Encrypted value to unwrap +--! @return jsonb Raw JSONB encrypted payload +--! +--! @note Returns the raw encrypted structure including ciphertext and index terms +--! @see eql_v2.to_encrypted(jsonb) CREATE FUNCTION eql_v2.to_jsonb(e public.eql_v2_encrypted) RETURNS jsonb IMMUTABLE STRICT PARALLEL SAFE @@ -72,10 +94,12 @@ BEGIN END; $$ LANGUAGE plpgsql; --- --- Cast eql_v2.encrypted to jsonb --- - +--! @brief Implicit cast from encrypted type to JSONB +--! +--! Enables PostgreSQL to automatically extract the JSONB payload from +--! eql_v2_encrypted values in assignment contexts. +--! +--! @see eql_v2.to_jsonb(eql_v2_encrypted) CREATE CAST (public.eql_v2_encrypted AS jsonb) WITH FUNCTION eql_v2.to_jsonb(public.eql_v2_encrypted) AS ASSIGNMENT; diff --git a/src/encrypted/compare.sql b/src/encrypted/compare.sql index 34aa4998..aff99d6b 100644 --- a/src/encrypted/compare.sql +++ b/src/encrypted/compare.sql @@ -1,10 +1,23 @@ -- REQUIRE: src/schema.sql -- REQUIRE: src/encrypted/types.sql --- --- Compare two eql_v2_encrypted values as literal jsonb values --- Used as a fallback when no suitable search term is available --- +--! @brief Fallback literal comparison for encrypted values +--! @internal +--! +--! Compares two encrypted values by their raw JSONB representation when no +--! suitable index terms are available. This ensures consistent ordering required +--! for btree correctness and prevents "lock BufferContent is not held" errors. +--! +--! Used as a last resort fallback in eql_v2.compare() when encrypted values +--! lack matching index terms (blake3, hmac_256, ore). +--! +--! @param a eql_v2_encrypted First encrypted value +--! @param b eql_v2_encrypted Second encrypted value +--! @return integer -1 if a < b, 0 if a = b, 1 if a > b +--! +--! @note This compares the encrypted payloads directly, not the plaintext values +--! @note Ordering is consistent but not meaningful for range queries +--! @see eql_v2.compare CREATE FUNCTION eql_v2.compare_literal(a eql_v2_encrypted, b eql_v2_encrypted) RETURNS integer IMMUTABLE STRICT PARALLEL SAFE diff --git a/src/encrypted/constraints.sql b/src/encrypted/constraints.sql index 8da1600a..fefcce27 100644 --- a/src/encrypted/constraints.sql +++ b/src/encrypted/constraints.sql @@ -3,7 +3,18 @@ -- REQUIRE: src/encrypted/functions.sql --- Should include an ident field +--! @brief Validate presence of ident field in encrypted payload +--! @internal +--! +--! Checks that the encrypted JSONB payload contains the required 'i' (ident) field. +--! The ident field tracks which table and column the encrypted value belongs to. +--! +--! @param val jsonb Encrypted payload to validate +--! @return Boolean True if 'i' field is present +--! @throws Exception if 'i' field is missing +--! +--! @note Used in CHECK constraints to ensure payload structure +--! @see eql_v2.check_encrypted CREATE FUNCTION eql_v2._encrypted_check_i(val jsonb) RETURNS boolean AS $$ @@ -16,7 +27,18 @@ AS $$ $$ LANGUAGE plpgsql; --- Ident field should include table and column +--! @brief Validate table and column fields in ident +--! @internal +--! +--! Checks that the 'i' (ident) field contains both 't' (table) and 'c' (column) +--! subfields, which identify the origin of the encrypted value. +--! +--! @param val jsonb Encrypted payload to validate +--! @return Boolean True if both 't' and 'c' subfields are present +--! @throws Exception if 't' or 'c' subfields are missing +--! +--! @note Used in CHECK constraints to ensure payload structure +--! @see eql_v2.check_encrypted CREATE FUNCTION eql_v2._encrypted_check_i_ct(val jsonb) RETURNS boolean AS $$ @@ -28,7 +50,18 @@ AS $$ END; $$ LANGUAGE plpgsql; --- -- Should include a version field +--! @brief Validate version field in encrypted payload +--! @internal +--! +--! Checks that the encrypted payload has version field 'v' set to '2', +--! the current EQL v2 payload version. +--! +--! @param val jsonb Encrypted payload to validate +--! @return Boolean True if 'v' field is present and equals '2' +--! @throws Exception if 'v' field is missing or not '2' +--! +--! @note Used in CHECK constraints to ensure payload structure +--! @see eql_v2.check_encrypted CREATE FUNCTION eql_v2._encrypted_check_v(val jsonb) RETURNS boolean AS $$ @@ -47,7 +80,18 @@ AS $$ $$ LANGUAGE plpgsql; --- -- Should include a ciphertext field +--! @brief Validate ciphertext field in encrypted payload +--! @internal +--! +--! Checks that the encrypted payload contains the required 'c' (ciphertext) field +--! which stores the encrypted data. +--! +--! @param val jsonb Encrypted payload to validate +--! @return Boolean True if 'c' field is present +--! @throws Exception if 'c' field is missing +--! +--! @note Used in CHECK constraints to ensure payload structure +--! @see eql_v2.check_encrypted CREATE FUNCTION eql_v2._encrypted_check_c(val jsonb) RETURNS boolean AS $$ @@ -60,6 +104,28 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Validate complete encrypted payload structure +--! +--! Comprehensive validation function that checks all required fields in an +--! encrypted JSONB payload: version ('v'), ciphertext ('c'), ident ('i'), +--! and ident subfields ('t', 'c'). +--! +--! This function is used in CHECK constraints to ensure encrypted column +--! data integrity at the database level. +--! +--! @param val jsonb Encrypted payload to validate +--! @return Boolean True if all structure checks pass +--! @throws Exception if any required field is missing or invalid +--! +--! @example +--! -- Add validation constraint to encrypted column +--! ALTER TABLE users ADD CONSTRAINT check_email_encrypted +--! CHECK (eql_v2.check_encrypted(encrypted_email::jsonb)); +--! +--! @see eql_v2._encrypted_check_v +--! @see eql_v2._encrypted_check_c +--! @see eql_v2._encrypted_check_i +--! @see eql_v2._encrypted_check_i_ct CREATE FUNCTION eql_v2.check_encrypted(val jsonb) RETURNS BOOLEAN LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE @@ -73,6 +139,16 @@ BEGIN ATOMIC END; +--! @brief Validate encrypted composite type structure +--! +--! Validates an eql_v2_encrypted composite type by checking its underlying +--! JSONB payload. Delegates to eql_v2.check_encrypted(jsonb). +--! +--! @param val eql_v2_encrypted Encrypted value to validate +--! @return Boolean True if structure is valid +--! @throws Exception if any required field is missing or invalid +--! +--! @see eql_v2.check_encrypted(jsonb) CREATE FUNCTION eql_v2.check_encrypted(val eql_v2_encrypted) RETURNS BOOLEAN LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE diff --git a/src/encryptindex/functions.sql b/src/encryptindex/functions.sql index 96c8d2e6..0ac4d628 100644 --- a/src/encryptindex/functions.sql +++ b/src/encryptindex/functions.sql @@ -1,7 +1,28 @@ --- Return the diff of two configurations --- Returns the set of keys in a that have different values to b --- The json comparison is on object values held by the key - +--! @file encryptindex/functions.sql +--! @brief Configuration lifecycle and column encryption management +--! +--! Provides functions for managing encryption configuration transitions: +--! - Comparing configurations to identify changes +--! - Identifying columns needing encryption +--! - Creating and renaming encrypted columns during initial setup +--! - Tracking encryption progress +--! +--! These functions support the workflow of activating a pending configuration +--! and performing the initial encryption of plaintext columns. + + +--! @brief Compare two configurations and find differences +--! @internal +--! +--! Returns table/column pairs where configuration differs between two configs. +--! Used to identify which columns need encryption when activating a pending config. +--! +--! @param a jsonb First configuration to compare +--! @param b jsonb Second configuration to compare +--! @return TABLE(table_name text, column_name text) Columns with differing configuration +--! +--! @note Compares configuration structure, not just presence/absence +--! @see eql_v2.select_pending_columns CREATE FUNCTION eql_v2.diff_config(a JSONB, b JSONB) RETURNS TABLE(table_name TEXT, column_name TEXT) IMMUTABLE STRICT PARALLEL SAFE @@ -31,9 +52,17 @@ AS $$ $$ LANGUAGE plpgsql; --- Returns the set of columns with pending configuration changes --- Compares the columns in pending configuration that do not match the active config - +--! @brief Get columns with pending configuration changes +--! +--! Compares 'pending' and 'active' configurations to identify columns that need +--! encryption or re-encryption. Returns columns where configuration differs. +--! +--! @return TABLE(table_name text, column_name text) Columns needing encryption +--! @throws Exception if no pending configuration exists +--! +--! @note Treats missing active config as empty config +--! @see eql_v2.diff_config +--! @see eql_v2.select_target_columns CREATE FUNCTION eql_v2.select_pending_columns() RETURNS TABLE(table_name TEXT, column_name TEXT) AS $$ @@ -61,16 +90,19 @@ AS $$ END; $$ LANGUAGE plpgsql; --- --- Returns the target columns with pending configuration --- --- A `pending` column may be either a plaintext variant or eql_v2_encrypted. --- A `target` column is always of type eql_v2_encrypted --- --- On initial encryption from plaintext the target column will be `{column_name}_encrypted ` --- OR NULL if the column does not exist --- +--! @brief Map pending columns to their encrypted target columns +--! +--! For each column with pending configuration, identifies the corresponding +--! encrypted column. During initial encryption, target is '{column_name}_encrypted'. +--! Returns NULL for target_column if encrypted column doesn't exist yet. +--! +--! @return TABLE(table_name text, column_name text, target_column text) Column mappings +--! +--! @note Target column is NULL if no column exists matching either 'column_name' or 'column_name_encrypted' with type eql_v2_encrypted +--! @note The LEFT JOIN checks both original and '_encrypted' suffix variations with type verification +--! @see eql_v2.select_pending_columns +--! @see eql_v2.create_encrypted_columns CREATE FUNCTION eql_v2.select_target_columns() RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT) STABLE STRICT PARALLEL SAFE @@ -88,9 +120,16 @@ AS $$ $$ LANGUAGE sql; --- --- Returns true if all pending columns have a target (encrypted) column - +--! @brief Check if database is ready for encryption +--! +--! Verifies that all columns with pending configuration have corresponding +--! encrypted target columns created. Returns true if encryption can proceed. +--! +--! @return boolean True if all pending columns have target encrypted columns +--! +--! @note Returns false if any pending column lacks encrypted column +--! @see eql_v2.select_target_columns +--! @see eql_v2.create_encrypted_columns CREATE FUNCTION eql_v2.ready_for_encryption() RETURNS BOOLEAN STABLE STRICT PARALLEL SAFE @@ -102,14 +141,18 @@ AS $$ $$ LANGUAGE sql; --- --- Creates eql_v2_encrypted columns for any plaintext columns with pending configuration --- The new column name is `{column_name}_encrypted` --- --- Executes the ALTER TABLE statement --- `ALTER TABLE {target_table} ADD COLUMN {column_name}_encrypted eql_v2_encrypted;` --- - +--! @brief Create encrypted columns for initial encryption +--! +--! For each plaintext column with pending configuration that lacks an encrypted +--! target column, creates a new column '{column_name}_encrypted' of type +--! eql_v2_encrypted. This prepares the database schema for initial encryption. +--! +--! @return TABLE(table_name text, column_name text) Created encrypted columns +--! +--! @warning Executes dynamic DDL (ALTER TABLE ADD COLUMN) - modifies database schema +--! @note Only creates columns that don't already exist +--! @see eql_v2.select_target_columns +--! @see eql_v2.rename_encrypted_columns CREATE FUNCTION eql_v2.create_encrypted_columns() RETURNS TABLE(table_name TEXT, column_name TEXT) AS $$ @@ -124,16 +167,19 @@ AS $$ $$ LANGUAGE plpgsql; --- --- Renames plaintext and eql_v2_encrypted columns created for the initial encryption. --- The source plaintext column is renamed to `{column_name}_plaintext` --- The target encrypted column is renamed from `{column_name}_encrypted` to `{column_name}` --- --- Executes the ALTER TABLE statements --- `ALTER TABLE {target_table} RENAME COLUMN {column_name} TO {column_name}_plaintext; --- `ALTER TABLE {target_table} RENAME COLUMN {column_name}_encrypted TO {column_name};` --- - +--! @brief Finalize initial encryption by renaming columns +--! +--! After initial encryption completes, renames columns to complete the transition: +--! - Plaintext column '{column_name}' → '{column_name}_plaintext' +--! - Encrypted column '{column_name}_encrypted' → '{column_name}' +--! +--! This makes the encrypted column the primary column with the original name. +--! +--! @return TABLE(table_name text, column_name text, target_column text) Renamed columns +--! +--! @warning Executes dynamic DDL (ALTER TABLE RENAME COLUMN) - modifies database schema +--! @note Only renames columns where target is '{column_name}_encrypted' +--! @see eql_v2.create_encrypted_columns CREATE FUNCTION eql_v2.rename_encrypted_columns() RETURNS TABLE(table_name TEXT, column_name TEXT, target_column TEXT) AS $$ @@ -149,7 +195,18 @@ AS $$ $$ LANGUAGE plpgsql; - +--! @brief Count rows encrypted with active configuration +--! @internal +--! +--! Counts rows in a table where the encrypted column was encrypted using +--! the currently active configuration. Used to track encryption progress. +--! +--! @param table_name text Name of table to check +--! @param column_name text Name of encrypted column to check +--! @return bigint Count of rows encrypted with active configuration +--! +--! @note The 'v' field in encrypted payloads stores the payload version ("2"), not the configuration ID +--! @note Configuration tracking mechanism is implementation-specific CREATE FUNCTION eql_v2.count_encrypted_with_active_config(table_name TEXT, column_name TEXT) RETURNS BIGINT AS $$ diff --git a/src/jsonb/functions.sql b/src/jsonb/functions.sql index a17675f6..8594cd76 100644 --- a/src/jsonb/functions.sql +++ b/src/jsonb/functions.sql @@ -1,27 +1,34 @@ -- REQUIRE: src/schema.sql -- REQUIRE: src/encrypted/types.sql --- The jsonpath operators @? and @@ suppress the following errors: --- missing object field or array element, --- unexpected JSON item type, --- datetime and numeric errors. --- The jsonpath-related functions described below can also be told to suppress these types of errors. --- This behavior might be helpful when searching JSON document collections of varying structure. - - - --- --- --- Returns the stevec encrypted element matching the selector --- --- If the selector is not found, the function returns NULL --- If the selector is found, the function returns the matching element --- --- Array elements use the same selector --- Multiple matching elements are wrapped into an eql_v2_encrypted with an array flag --- --- - +--! @file jsonb/functions.sql +--! @brief JSONB path query and array manipulation functions for encrypted data +--! +--! These functions provide PostgreSQL-compatible operations on encrypted JSONB values +--! using Structured Transparent Encryption (STE). They support: +--! - Path-based queries to extract nested encrypted values +--! - Existence checks for encrypted fields +--! - Array operations (length, elements extraction) +--! +--! @note STE stores encrypted JSONB as a vector of encrypted elements ('sv') with selectors +--! @note Functions suppress errors for missing fields, type mismatches (similar to PostgreSQL jsonpath) + + +--! @brief Query encrypted JSONB for elements matching selector +--! +--! Searches the Structured Transparent Encryption (STE) vector for elements matching +--! the given selector path. Returns all matching encrypted elements. If multiple +--! matches form an array, they are wrapped with array metadata. +--! +--! @param val jsonb Encrypted JSONB payload containing STE vector ('sv') +--! @param selector text Path selector to match against encrypted elements +--! @return SETOF eql_v2_encrypted Matching encrypted elements (may return multiple rows) +--! +--! @note Returns empty set if selector is not found (does not throw exception) +--! @note Array elements use same selector; multiple matches wrapped with 'a' flag +--! @note Returns a set containing NULL if val is NULL; returns empty set if no matches found +--! @see eql_v2.jsonb_path_query_first +--! @see eql_v2.jsonb_path_exists CREATE FUNCTION eql_v2.jsonb_path_query(val jsonb, selector text) RETURNS SETOF eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE @@ -76,6 +83,16 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Query encrypted JSONB with encrypted selector +--! +--! Overload that accepts encrypted selector and extracts its plaintext value +--! before delegating to main jsonb_path_query implementation. +--! +--! @param val eql_v2_encrypted Encrypted JSONB value to query +--! @param selector eql_v2_encrypted Encrypted selector to match against +--! @return SETOF eql_v2_encrypted Matching encrypted elements +--! +--! @see eql_v2.jsonb_path_query(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector eql_v2_encrypted) RETURNS SETOF eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE @@ -87,6 +104,20 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Query encrypted JSONB with text selector +--! +--! Overload that accepts encrypted JSONB value and text selector, +--! extracting the JSONB payload before querying. +--! +--! @param val eql_v2_encrypted Encrypted JSONB value to query +--! @param selector text Path selector to match against +--! @return SETOF eql_v2_encrypted Matching encrypted elements +--! +--! @example +--! -- Query encrypted JSONB for specific field +--! SELECT * FROM eql_v2.jsonb_path_query(encrypted_document, '$.address.city'); +--! +--! @see eql_v2.jsonb_path_query(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_query(val eql_v2_encrypted, selector text) RETURNS SETOF eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE @@ -101,6 +132,16 @@ $$ LANGUAGE plpgsql; ------------------------------------------------------------------------------------ +--! @brief Check if selector path exists in encrypted JSONB +--! +--! Tests whether any encrypted elements match the given selector path. +--! More efficient than jsonb_path_query when only existence check is needed. +--! +--! @param val jsonb Encrypted JSONB payload to check +--! @param selector text Path selector to test +--! @return boolean True if matching element exists, false otherwise +--! +--! @see eql_v2.jsonb_path_query(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_exists(val jsonb, selector text) RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE @@ -113,6 +154,16 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Check existence with encrypted selector +--! +--! Overload that accepts encrypted selector and extracts its value +--! before checking existence. +--! +--! @param val eql_v2_encrypted Encrypted JSONB value to check +--! @param selector eql_v2_encrypted Encrypted selector to test +--! @return boolean True if path exists +--! +--! @see eql_v2.jsonb_path_exists(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector eql_v2_encrypted) RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE @@ -125,6 +176,19 @@ AS $$ $$ LANGUAGE plpgsql; +--! @brief Check existence with text selector +--! +--! Overload that accepts encrypted JSONB value and text selector. +--! +--! @param val eql_v2_encrypted Encrypted JSONB value to check +--! @param selector text Path selector to test +--! @return boolean True if path exists +--! +--! @example +--! -- Check if encrypted document has address field +--! SELECT eql_v2.jsonb_path_exists(encrypted_document, '$.address'); +--! +--! @see eql_v2.jsonb_path_exists(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_exists(val eql_v2_encrypted, selector text) RETURNS boolean IMMUTABLE STRICT PARALLEL SAFE @@ -140,45 +204,78 @@ $$ LANGUAGE plpgsql; ------------------------------------------------------------------------------------ +--! @brief Get first element matching selector +--! +--! Returns only the first encrypted element matching the selector path, +--! or NULL if no match found. More efficient than jsonb_path_query when +--! only one result is needed. +--! +--! @param val jsonb Encrypted JSONB payload to query +--! @param selector text Path selector to match +--! @return eql_v2_encrypted First matching element or NULL +--! +--! @note Uses LIMIT 1 internally for efficiency +--! @see eql_v2.jsonb_path_query(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_query_first(val jsonb, selector text) RETURNS eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE AS $$ BEGIN RETURN ( - SELECT ( - SELECT e - FROM eql_v2.jsonb_path_query(val.data, selector) AS e - LIMIT 1 - ) + SELECT e + FROM eql_v2.jsonb_path_query(val, selector) AS e + LIMIT 1 ); END; $$ LANGUAGE plpgsql; +--! @brief Get first element with encrypted selector +--! +--! Overload that accepts encrypted selector and extracts its value +--! before querying for first match. +--! +--! @param val eql_v2_encrypted Encrypted JSONB value to query +--! @param selector eql_v2_encrypted Encrypted selector to match +--! @return eql_v2_encrypted First matching element or NULL +--! +--! @see eql_v2.jsonb_path_query_first(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector eql_v2_encrypted) RETURNS eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE AS $$ BEGIN RETURN ( - SELECT e - FROM eql_v2.jsonb_path_query(val.data, eql_v2.selector(selector)) as e - LIMIT 1 + SELECT e + FROM eql_v2.jsonb_path_query(val.data, eql_v2.selector(selector)) AS e + LIMIT 1 ); END; $$ LANGUAGE plpgsql; +--! @brief Get first element with text selector +--! +--! Overload that accepts encrypted JSONB value and text selector. +--! +--! @param val eql_v2_encrypted Encrypted JSONB value to query +--! @param selector text Path selector to match +--! @return eql_v2_encrypted First matching element or NULL +--! +--! @example +--! -- Get first matching address from encrypted document +--! SELECT eql_v2.jsonb_path_query_first(encrypted_document, '$.addresses[*]'); +--! +--! @see eql_v2.jsonb_path_query_first(jsonb, text) CREATE FUNCTION eql_v2.jsonb_path_query_first(val eql_v2_encrypted, selector text) RETURNS eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE AS $$ BEGIN RETURN ( - SELECT e - FROM eql_v2.jsonb_path_query(val.data, selector) as e - LIMIT 1 + SELECT e + FROM eql_v2.jsonb_path_query(val.data, selector) AS e + LIMIT 1 ); END; $$ LANGUAGE plpgsql; @@ -188,13 +285,18 @@ $$ LANGUAGE plpgsql; ------------------------------------------------------------------------------------ --- ===================================================================== --- --- Returns the length of an encrypted jsonb array ---- --- An encrypted is a jsonb array if it contains an "a" field/attribute with a truthy value --- - +--! @brief Get length of encrypted JSONB array +--! +--! Returns the number of elements in an encrypted JSONB array by counting +--! elements in the STE vector ('sv'). The encrypted value must have the +--! array flag ('a') set to true. +--! +--! @param val jsonb Encrypted JSONB payload representing an array +--! @return integer Number of elements in the array +--! @throws Exception 'cannot get array length of a non-array' if 'a' flag is missing or not true +--! +--! @note Array flag 'a' must be present and set to true value +--! @see eql_v2.jsonb_array_elements CREATE FUNCTION eql_v2.jsonb_array_length(val jsonb) RETURNS integer IMMUTABLE STRICT PARALLEL SAFE @@ -218,7 +320,20 @@ AS $$ $$ LANGUAGE plpgsql; - +--! @brief Get array length from encrypted type +--! +--! Overload that accepts encrypted composite type and extracts the +--! JSONB payload before computing array length. +--! +--! @param val eql_v2_encrypted Encrypted array value +--! @return integer Number of elements in the array +--! @throws Exception if value is not an array +--! +--! @example +--! -- Get length of encrypted array +--! SELECT eql_v2.jsonb_array_length(encrypted_tags); +--! +--! @see eql_v2.jsonb_array_length(jsonb) CREATE FUNCTION eql_v2.jsonb_array_length(val eql_v2_encrypted) RETURNS integer IMMUTABLE STRICT PARALLEL SAFE @@ -233,13 +348,19 @@ $$ LANGUAGE plpgsql; --- ===================================================================== --- --- Returns the length of an encrypted jsonb array ---- --- An encrypted is a jsonb array if it contains an "a" field/attribute with a truthy value --- - +--! @brief Extract elements from encrypted JSONB array +--! +--! Returns each element of an encrypted JSONB array as a separate row. +--! Each element is returned as an eql_v2_encrypted value with metadata +--! preserved from the parent array. +--! +--! @param val jsonb Encrypted JSONB payload representing an array +--! @return SETOF eql_v2_encrypted One row per array element +--! @throws Exception if value is not an array (missing 'a' flag) +--! +--! @note Each element inherits metadata (version, ident) from parent +--! @see eql_v2.jsonb_array_length +--! @see eql_v2.jsonb_array_elements_text CREATE FUNCTION eql_v2.jsonb_array_elements(val jsonb) RETURNS SETOF eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE @@ -269,7 +390,20 @@ AS $$ $$ LANGUAGE plpgsql; - +--! @brief Extract elements from encrypted array type +--! +--! Overload that accepts encrypted composite type and extracts each +--! array element as a separate row. +--! +--! @param val eql_v2_encrypted Encrypted array value +--! @return SETOF eql_v2_encrypted One row per array element +--! @throws Exception if value is not an array +--! +--! @example +--! -- Expand encrypted array into rows +--! SELECT * FROM eql_v2.jsonb_array_elements(encrypted_tags); +--! +--! @see eql_v2.jsonb_array_elements(jsonb) CREATE FUNCTION eql_v2.jsonb_array_elements(val eql_v2_encrypted) RETURNS SETOF eql_v2_encrypted IMMUTABLE STRICT PARALLEL SAFE @@ -282,13 +416,18 @@ $$ LANGUAGE plpgsql; --- ===================================================================== --- --- Returns the length of an encrypted jsonb array ---- --- An encrypted is a jsonb array if it contains an "a" field/attribute with a truthy value --- - +--! @brief Extract encrypted array elements as ciphertext +--! +--! Returns each element of an encrypted JSONB array as its raw ciphertext +--! value (text representation). Unlike jsonb_array_elements, this returns +--! only the ciphertext 'c' field without metadata. +--! +--! @param val jsonb Encrypted JSONB payload representing an array +--! @return SETOF text One ciphertext string per array element +--! @throws Exception if value is not an array (missing 'a' flag) +--! +--! @note Returns ciphertext only, not full encrypted structure +--! @see eql_v2.jsonb_array_elements CREATE FUNCTION eql_v2.jsonb_array_elements_text(val jsonb) RETURNS SETOF text IMMUTABLE STRICT PARALLEL SAFE @@ -312,7 +451,20 @@ AS $$ $$ LANGUAGE plpgsql; - +--! @brief Extract array elements as ciphertext from encrypted type +--! +--! Overload that accepts encrypted composite type and extracts each +--! array element's ciphertext as text. +--! +--! @param val eql_v2_encrypted Encrypted array value +--! @return SETOF text One ciphertext string per array element +--! @throws Exception if value is not an array +--! +--! @example +--! -- Get ciphertext of each array element +--! SELECT * FROM eql_v2.jsonb_array_elements_text(encrypted_tags); +--! +--! @see eql_v2.jsonb_array_elements_text(jsonb) CREATE FUNCTION eql_v2.jsonb_array_elements_text(val eql_v2_encrypted) RETURNS SETOF text IMMUTABLE STRICT PARALLEL SAFE diff --git a/src/operators/<>.sql b/src/operators/<>.sql index c32a1e1b..3b0f2560 100644 --- a/src/operators/<>.sql +++ b/src/operators/<>.sql @@ -83,6 +83,11 @@ CREATE OPERATOR <> ( ); --! @brief <> operator for JSONB and encrypted value +--! +--! @param a jsonb Plain JSONB value +--! @param b eql_v2_encrypted Encrypted value +--! @return boolean True if values are not equal +--! --! @see eql_v2."<>"(eql_v2_encrypted, eql_v2_encrypted) CREATE FUNCTION eql_v2."<>"(a jsonb, b eql_v2_encrypted) RETURNS boolean diff --git a/src/operators/~~.sql b/src/operators/~~.sql index 28189c23..7467376b 100644 --- a/src/operators/~~.sql +++ b/src/operators/~~.sql @@ -63,6 +63,12 @@ $$ LANGUAGE SQL; --! SELECT * FROM customers --! WHERE encrypted_name ~~ 'John%'::text::eql_v2_encrypted; --! +--! @brief SQL LIKE operator (~~ operator) for encrypted text pattern matching +--! +--! @param a eql_v2_encrypted Left operand (encrypted value) +--! @param b eql_v2_encrypted Right operand (encrypted pattern) +--! @return boolean True if pattern matches +--! --! @note Requires match index: eql_v2.add_search_config(table, column, 'match') --! @see eql_v2.like --! @see eql_v2.add_search_config diff --git a/src/schema.sql b/src/schema.sql index dd9386a7..bbdfc776 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -1,2 +1,17 @@ +--! @file schema.sql +--! @brief EQL v2 schema creation +--! +--! Creates the eql_v2 schema which contains all Encrypt Query Language +--! functions, types, and tables. Drops existing schema if present to +--! support clean reinstallation. +--! +--! @warning DROP SCHEMA CASCADE will remove all objects in the schema +--! @note All EQL objects (functions, types, tables) reside in eql_v2 schema + +--! @brief Drop existing EQL v2 schema +--! @warning CASCADE will drop all dependent objects DROP SCHEMA IF EXISTS eql_v2 CASCADE; + +--! @brief Create EQL v2 schema +--! @note All EQL functions and types will be created in this schema CREATE SCHEMA eql_v2; diff --git a/src/version.template b/src/version.template index d98c76b1..dc778e12 100644 --- a/src/version.template +++ b/src/version.template @@ -4,6 +4,25 @@ DROP FUNCTION IF EXISTS eql_v2.version(); +--! @file version.sql +--! @brief EQL version reporting +--! +--! This file is auto-generated from version.template during build. +--! The version string placeholder is replaced with the actual release version. + +--! @brief Get EQL library version string +--! +--! Returns the version string for the installed EQL library. +--! This value is set at build time from the project version. +--! +--! @return text Version string (e.g., "2.1.0" or "DEV" for development builds) +--! +--! @note Auto-generated during build from version.template +--! +--! @example +--! -- Check installed EQL version +--! SELECT eql_v2.version(); +--! -- Returns: '2.1.0' CREATE FUNCTION eql_v2.version() RETURNS text IMMUTABLE STRICT PARALLEL SAFE diff --git a/tasks/docs/doxygen-filter.sh b/tasks/docs/doxygen-filter.sh new file mode 100755 index 00000000..ab196dd9 --- /dev/null +++ b/tasks/docs/doxygen-filter.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Doxygen input filter for SQL files" + +# Converts SQL-style comments (--!) to C++-style comments (//!) +sed 's/^--!/\/\/!/g' "$1" diff --git a/tasks/docs/generate.sh b/tasks/docs/generate.sh new file mode 100755 index 00000000..bebfd3a3 --- /dev/null +++ b/tasks/docs/generate.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +#MISE description="Generate API documentation (with Doxygen)" + +set -e + +if ! which -s doxygen; then + echo "error: doxygen not installed" + exit 2 +fi + +echo "Generating API documentation..." +echo +doxygen Doxyfile +echo +echo "Documentation generated at docs/api/html/index.html" diff --git a/tasks/docs/package.sh b/tasks/docs/package.sh new file mode 100755 index 00000000..70bb3491 --- /dev/null +++ b/tasks/docs/package.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +#MISE description="Package documentation for release" + +set -e + +VERSION=${1:-"dev"} +OUTPUT_DIR="release" +DOCS_DIR="docs/api" + +echo "Packaging documentation for version: ${VERSION}" + +# Validate documentation exists +if [ ! -f "${DOCS_DIR}/html/index.html" ]; then + echo "Error: ${DOCS_DIR}/html/index.html not found" + echo "Run 'mise run docs:generate' first to generate documentation" + exit 1 +fi + +# Validate documentation directory has content +if [ ! -d "${DOCS_DIR}/html" ] || [ -z "$(ls -A ${DOCS_DIR}/html)" ]; then + echo "Error: ${DOCS_DIR}/html is empty or does not exist" + exit 1 +fi + +# Create output directory +mkdir -p "${OUTPUT_DIR}" + +# Create archives +echo "Creating archives..." +cd "${DOCS_DIR}" + +# Create ZIP archive +zip -r -q "../../${OUTPUT_DIR}/eql-docs-${VERSION}.zip" html/ +echo "Created ${OUTPUT_DIR}/eql-docs-${VERSION}.zip" + +# Create tarball +tar czf "../../${OUTPUT_DIR}/eql-docs-${VERSION}.tar.gz" html/ +echo "Created ${OUTPUT_DIR}/eql-docs-${VERSION}.tar.gz" + +cd ../.. + +# Verify archives created +if [ -f "${OUTPUT_DIR}/eql-docs-${VERSION}.zip" ] && [ -f "${OUTPUT_DIR}/eql-docs-${VERSION}.tar.gz" ]; then + echo "" + echo "Documentation packaged successfully:" + ls -lh "${OUTPUT_DIR}/eql-docs-${VERSION}".* + exit 0 +else + echo "Error: Failed to create archives" + exit 1 +fi diff --git a/tasks/docs/validate.sh b/tasks/docs/validate.sh new file mode 100755 index 00000000..39275596 --- /dev/null +++ b/tasks/docs/validate.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +#MISE description="Validate SQL documentation" + +set -e + +echo +echo "Checking documentation coverage..." +mise run --output prefix docs:validate:coverage + +echo +echo "Validating required tags..." +mise run --output prefix docs:validate:required-tags + +echo +echo "Validating SQL in documentation..." +mise run --output prefix docs:validate:documented-sql diff --git a/tasks/docs/validate/coverage.sh b/tasks/docs/validate/coverage.sh new file mode 100755 index 00000000..623f8f2f --- /dev/null +++ b/tasks/docs/validate/coverage.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +#MISE description="Checks documentation coverage for SQL files" + +set -e + +echo "# SQL Documentation Coverage Report" +echo "" +echo "Generated: $(date +"%Y-%m-%dT%H:%M:%S%z")" +echo "" + +source_directory="src" +total_sql_files=0 +documented_sql_files=0 + +if [ ! -d $source_directory ]; then + echo "error: source directory does not exist: ${source_directory}" + exit 2 +fi + +# Check .sql files +for file in $(find $source_directory -name "*.sql" -not -name "*_test.sql" | sort); do + # Skip auto-generated files + if grep -q "^-- AUTOMATICALLY GENERATED FILE" "$file" 2>/dev/null; then + echo "- $file: ⊘ Auto-generated (skipped)" + continue + fi + + total_sql_files=$((total_sql_files + 1)) + + if grep -q "^--! @brief" "$file" 2>/dev/null; then + echo "- $file: ✓ Documented" + documented_sql_files=$((documented_sql_files + 1)) + else + echo "- $file: ✗ No documentation" + fi +done + +# Check .template files +total_template_files=0 +documented_template_files=0 + +for file in $(find $source_directory -name "*.template" | sort); do + total_template_files=$((total_template_files + 1)) + + if grep -q "^--! @brief" "$file" 2>/dev/null; then + echo "- $file: ✓ Documented" + documented_template_files=$((documented_template_files + 1)) + else + echo "- $file: ✗ No documentation" + fi +done + +total_files=$((total_sql_files + total_template_files)) +documented_files=$((documented_sql_files + documented_template_files)) + +echo "" +echo "## Summary" +echo "" +echo "- SQL files: $documented_sql_files/$total_sql_files" +echo "- Template files: $documented_template_files/$total_template_files" +echo "- Total files: $documented_files/$total_files" + +if [ $total_files -gt 0 ]; then + coverage=$((documented_files * 100 / total_files)) + echo "- Coverage: ${coverage}%" +else + coverage=0 +fi + +echo "" + +if [ $coverage -eq 100 ]; then + echo "✅ 100% documentation coverage achieved!" + exit 0 +else + echo "⚠️ Documentation coverage: ${coverage}%" + exit 1 +fi diff --git a/tasks/docs/validate/documented-sql.sh b/tasks/docs/validate/documented-sql.sh new file mode 100755 index 00000000..0db2cf1a --- /dev/null +++ b/tasks/docs/validate/documented-sql.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +#MISE description="Validates SQL syntax for all documented files" + +set -e + +PGHOST=${PGHOST:-localhost} +PGPORT=${PGPORT:-7432} +PGUSER=${PGUSER:-cipherstash} +PGPASSWORD=${PGPASSWORD:-password} +PGDATABASE=${PGDATABASE:-postgres} +source_directory="src" + +echo "Validating SQL syntax for all documented files..." +echo "" + +errors=0 +validated=0 + +if [ ! -d $source_directory ]; then + echo "error: source directory does not exist: ${source_directory}" + exit 2 +fi + +for file in $(find $source_directory -name "*.sql" -not -name "*_test.sql" | sort); do + echo -n "Validating $file... " + + # Capture both stdout and stderr + error_output=$(PGPASSWORD="$PGPASSWORD" psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \ + -f "$file" --set ON_ERROR_STOP=1 -q 2>&1) || exit_code=$? + + if [ "${exit_code:-0}" -eq 0 ]; then + echo "✓" + validated=$((validated + 1)) + else + echo "✗ SYNTAX ERROR" + echo " Error in: $file" + echo " Details:" + echo "$error_output" | tail -10 | sed 's/^/ /' + echo "" + errors=$((errors + 1)) + fi + exit_code=0 +done + +echo "" +echo "Validation complete:" +echo " Validated: $validated" +echo " Errors: $errors" + +if [ $errors -gt 0 ]; then + echo "" + echo "❌ Validation failed with $errors errors" + exit 1 +else + echo "" + echo "✅ All SQL files validated successfully" + exit 0 +fi diff --git a/tasks/docs/validate/required-tags.sh b/tasks/docs/validate/required-tags.sh new file mode 100755 index 00000000..55e59557 --- /dev/null +++ b/tasks/docs/validate/required-tags.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +#MISE description="Validates required Doxygen tags are present" + +set -e + +echo "Validating required Doxygen tags..." +echo "" + +source_directory="src" +errors=0 +warnings=0 + +if [ ! -d $source_directory ]; then + echo "error: source directory does not exist: ${source_directory}" + exit 2 +fi + +for file in $(find $source_directory -name "*.sql" -not -name "*_test.sql"); do + # Skip auto-generated files + if grep -q "^-- AUTOMATICALLY GENERATED FILE" "$file" 2>/dev/null; then + continue + fi + + # For each CREATE FUNCTION, check tags + functions=$(grep -n "^CREATE FUNCTION" "$file" 2>/dev/null | cut -d: -f1 || echo "") + + for line_no in $functions; do + # Find comment block above function (search backwards max 50 lines) + start=$((line_no - 50)) + [ "$start" -lt 1 ] && start=1 + + comment_block=$(sed -n "${start},${line_no}p" "$file" | grep "^--!" | tail -100) + + function_sig=$(sed -n "${line_no}p" "$file") + # Extract function name (compatible with BSD sed/grep) + function_name=$(echo "$function_sig" | sed -n 's/^CREATE FUNCTION[[:space:]]*\([^(]*\).*/\1/p' | xargs || echo "unknown") + + # Check for @brief + if ! echo "$comment_block" | grep -q "@brief"; then + echo "ERROR: $file:$line_no $function_name - Missing @brief" + errors=$((errors + 1)) + fi + + # Check for @param (if function has parameters) + if echo "$function_sig" | grep -q "(" && \ + ! echo "$function_sig" | grep -q "()"; then + if ! echo "$comment_block" | grep -q "@param"; then + echo "WARNING: $file:$line_no $function_name - Missing @param" + warnings=$((warnings + 1)) + fi + fi + + # Check for @return (if function returns something other than void) + if ! echo "$function_sig" | grep -qi "RETURNS void"; then + if ! echo "$comment_block" | grep -q "@return"; then + echo "ERROR: $file:$line_no $function_name - Missing @return" + errors=$((errors + 1)) + fi + fi + done +done + +# Also check template files +for file in $(find $source_directory -name "*.template"); do + functions=$(grep -n "^CREATE FUNCTION" "$file" 2>/dev/null | cut -d: -f1 || echo "") + + for line_no in $functions; do + start=$((line_no - 50)) + [ "$start" -lt 1 ] && start=1 + + comment_block=$(sed -n "${start},${line_no}p" "$file" | grep "^--!" | tail -100) + + function_sig=$(sed -n "${line_no}p" "$file") + # Extract function name (compatible with BSD sed/grep) + function_name=$(echo "$function_sig" | sed -n 's/^CREATE FUNCTION[[:space:]]*\([^(]*\).*/\1/p' | xargs || echo "unknown") + + if ! echo "$comment_block" | grep -q "@brief"; then + echo "ERROR: $file:$line_no $function_name - Missing @brief" + errors=$((errors + 1)) + fi + + if echo "$function_sig" | grep -q "(" && \ + ! echo "$function_sig" | grep -q "()"; then + if ! echo "$comment_block" | grep -q "@param"; then + echo "WARNING: $file:$line_no $function_name - Missing @param" + warnings=$((warnings + 1)) + fi + fi + + if ! echo "$function_sig" | grep -qi "RETURNS void"; then + if ! echo "$comment_block" | grep -q "@return"; then + echo "ERROR: $file:$line_no $function_name - Missing @return" + errors=$((errors + 1)) + fi + fi + done +done + +echo "" +echo "Validation summary:" +echo " Errors: $errors" +echo " Warnings: $warnings" +echo "" + +if [ "$errors" -gt 0 ]; then + echo "❌ Validation failed with $errors errors" + exit 1 +else + echo "✅ All required tags present" + exit 0 +fi