Skip to content

feat: add declarative database tool templates#106

Merged
QueryPlanner merged 2 commits into
mainfrom
feat/declarative-database-templates
Jun 12, 2026
Merged

feat: add declarative database tool templates#106
QueryPlanner merged 2 commits into
mainfrom
feat/declarative-database-templates

Conversation

@QueryPlanner

Copy link
Copy Markdown
Owner

What

Add a safe, sandboxed declarative database capability layer and custom instructions system for Blacki.

Why

This allows the AI agent to define lightweight custom schemas, query templates, and instruction overrides dynamically and securely per user/chat session without generating arbitrary executable python code, modifying core prompts, or risking SQL injection.

How

  • Safety and Validation Layer: Implement strict regex matching for table/column identifiers and a case-insensitive blocklist of SQL keywords.
  • Storage Layer: Implement SqliteDeclarativeDbStorage(SqlStorage) in src/blacki/declarative_db/storage.py with multi-user isolation (user-id scoped metadata and md5-hashed physical table prefixing), safe dynamic parameterized query compilation, and transactional schema safety.
  • ADK Integration Plugin: Implement DeclarativeDbPlugin(BasePlugin) in src/blacki/declarative_db/plugin.py to compile active schemas/templates into XML and append them to system instructions in before_model_callback without altering conversation turns or system prompt code.
  • Exposed ADK Tools: Expose tools for custom tables CRUD, template registration, secure parameterized dynamic query execution with key validation, and custom instruction overrides.
  • Tests: Implement exhaustive validation, storage integration, plugin context, and tools coverage in tests/declarative_db/test_declarative_db.py.

Tests

  • Static validation and identifier keyword/length unit checks
  • Physical table creation, columns metadata PRAGMA checks, and table/template limits guardrails
  • Parameter key SQL injection and metadata column matching security checks
  • Template SELECT, INSERT, UPDATE, and DELETE parameterized execution integration checks
  • ADK Plugin instruction compilation and injection system tests
  • Agent Tools proxy schema discovery and CRUD validation tests
  • All checks successfully run and pass via uv run ruff check, uv run ruff format, uv run mypy ., and uv run pytest (875/875 passing tests)

Related Issues

Closes #103

- Add safety validation layer for identifiers and column types
- Implement SqliteDeclarativeDbStorage storage layer for templates
- Integrate declarative db storage with AppContainer
- Implement ADK plugin to inject custom schema XML in before_model
- Register declarative db tools in registry.py and app agent
- Write extensive unit and integration tests in tests/declarative_db/

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a declarative database feature, allowing users to dynamically define custom tables, query templates, and instruction overrides. It includes a new ADK plugin, an SQLite storage layer, validation logic, agent-facing tools, and comprehensive tests. The review feedback highlights several important improvements for the storage layer: preventing metadata desynchronization when recreating existing tables, allowing updates to query templates even when the template limit is reached, safely handling boolean and complex default values in DDL construction, and using async with blocks for database cursors during UPDATE and DELETE operations to avoid resource leaks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/blacki/declarative_db/storage.py Outdated
Comment on lines +165 to +172
# Check table counts guardrail
existing_count_row = await self._fetch_one(
"SELECT COUNT(*) as count FROM custom_tables WHERE user_id = ?",
(user_id,),
)
existing_count = existing_count_row["count"] if existing_count_row else 0
if existing_count >= 5:
raise ValueError("Limit of 5 custom tables per user reached")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If create_custom_table is called for an existing table, CREATE TABLE IF NOT EXISTS does not modify the physical SQLite table. However, the metadata in custom_table_columns is completely deleted and replaced with the new column definitions. This leads to a critical desynchronization where the metadata columns do not match the physical table columns, causing subsequent queries to fail.

To prevent this, check if the table already exists and raise an error, forcing the user to explicitly delete the table first if they want to modify its schema.

            # Check if table already exists to prevent desynchronization
            exists_row = await self._fetch_one(
                "SELECT 1 FROM custom_tables WHERE user_id = ? AND table_name = ?",
                (user_id, table_name),
            )
            if exists_row:
                raise ValueError(
                    f"Table '{table_name}' already exists. "
                    "You must delete it first to modify its schema."
                )

            # Check table counts guardrail
            existing_count_row = await self._fetch_one(
                "SELECT COUNT(*) as count FROM custom_tables WHERE user_id = ?",
                (user_id,),
            )
            existing_count = existing_count_row["count"] if existing_count_row else 0
            if existing_count >= 5:
                raise ValueError("Limit of 5 custom tables per user reached")

Comment thread src/blacki/declarative_db/storage.py Outdated
Comment on lines +304 to +311
# Check templates limit (10 per user)
existing_count_row = await self._fetch_one(
"SELECT COUNT(*) as count FROM saved_query_templates WHERE user_id = ?",
(user_id,),
)
existing_count = existing_count_row["count"] if existing_count_row else 0
if existing_count >= 10:
raise ValueError("Limit of 10 saved query templates per user reached")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The template limit check (existing_count >= 10) is enforced even when updating/overwriting an existing template. Once a user reaches the limit of 10 templates, they will be blocked from updating any of their existing templates because the count remains at 10.

To fix this, check if the template already exists first, and only enforce the limit for new templates.

            # Check if template already exists to allow updates without hitting the limit
            exists_row = await self._fetch_one(
                "SELECT 1 FROM saved_query_templates WHERE user_id = ? AND template_name = ?",
                (user_id, template_name),
            )
            if not exists_row:
                existing_count_row = await self._fetch_one(
                    "SELECT COUNT(*) as count FROM saved_query_templates WHERE user_id = ?",
                    (user_id,),
                )
                existing_count = existing_count_row["count"] if existing_count_row else 0
                if existing_count >= 10:
                    raise ValueError("Limit of 10 saved query templates per user reached")

Comment thread src/blacki/declarative_db/storage.py Outdated
Comment on lines +145 to +151
if default_val is not None:
# Basic escaping of default string values if string-like
if isinstance(default_val, str):
escaped_default = default_val.replace("'", "''")
part += f" DEFAULT '{escaped_default}'"
else:
part += f" DEFAULT {default_val}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When constructing the column DDL, default_val is appended directly to the SQL string if it is not a string. If default_val is a boolean, Python's str(True) produces True, resulting in DEFAULT True. While newer SQLite versions support boolean literals, older versions do not and will fail. Furthermore, if default_val is a complex type like a list or dict, it will cause a SQL syntax error.

To ensure compatibility and safety, explicitly handle booleans (converting them to 1 or 0) and restrict the allowed default value types to scalar types (str, int, float, bool).

Suggested change
if default_val is not None:
# Basic escaping of default string values if string-like
if isinstance(default_val, str):
escaped_default = default_val.replace("'", "''")
part += f" DEFAULT '{escaped_default}'"
else:
part += f" DEFAULT {default_val}"
if default_val is not None:
if isinstance(default_val, bool):
part += f" DEFAULT {1 if default_val else 0}"
elif isinstance(default_val, str):
escaped_default = default_val.replace("'", "''")
part += f" DEFAULT '{escaped_default}'"
elif isinstance(default_val, (int, float)):
part += f" DEFAULT {default_val}"
else:
raise ValueError(f"Unsupported default value type: {type(default_val).__name__}")

Comment on lines +521 to +524
# Run write update
async with self._lock:
cursor = await self._conn.execute(sql, bindings)
return cursor.rowcount

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In execute_query_template, the UPDATE query execution does not use an async with block for the cursor, unlike the SELECT and INSERT paths. This can leave cursors unclosed, potentially leaking database resources.

Use async with self._conn.execute(...) as cursor: to ensure proper cleanup.

Suggested change
# Run write update
async with self._lock:
cursor = await self._conn.execute(sql, bindings)
return cursor.rowcount
# Run write update
async with self._lock:
async with self._conn.execute(sql, bindings) as cursor:
return cursor.rowcount

Comment on lines +542 to +545
# Run write delete
async with self._lock:
cursor = await self._conn.execute(sql, bindings)
return cursor.rowcount

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In execute_query_template, the DELETE query execution does not use an async with block for the cursor, unlike the SELECT and INSERT paths. This can leave cursors unclosed, potentially leaking database resources.

Use async with self._conn.execute(...) as cursor: to ensure proper cleanup.

Suggested change
# Run write delete
async with self._lock:
cursor = await self._conn.execute(sql, bindings)
return cursor.rowcount
# Run write delete
async with self._lock:
async with self._conn.execute(sql, bindings) as cursor:
return cursor.rowcount

- Prevent metadata desync when recreating existing tables

- Allow query template updates when limit is reached

- Format boolean and complex defaults safely in DDL

- Prevent UPDATE/DELETE template cursor resource leaks

- Achieve 100% test coverage with new unit and integration tests
@QueryPlanner QueryPlanner merged commit fcecbc7 into main Jun 12, 2026
1 check passed
@QueryPlanner QueryPlanner deleted the feat/declarative-database-templates branch June 12, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add declarative database tool templates

1 participant