Skip to content

Conversation

FunamaYukina
Copy link
Member

@FunamaYukina FunamaYukina commented Sep 26, 2025

⚠️ Please do not merge

Please do not merge this PR yet, as the base PR has not been merged.

Issue

Summary

Enhance the Drizzle PostgreSQL parser to correctly handle complex method chaining patterns like .enableRLS().$comment(). This fixes GitHub folder URL parsing for Drizzle schemas that use multiple method chaining patterns.

Issues Fixed

  • Complex method chaining patterns like .enableRLS().$comment() were not being parsed correctly
  • GitHub folder URL feature was only showing some tables instead of all tables from Drizzle schemas
  • Tables using complex method chaining were being ignored during parsing

Technical Details

AST Structure Analysis

The complex chaining pattern .enableRLS().$comment() creates this AST structure:

  1. Root call: $comment(...)
  2. Its callee object: enableRLS() call
  3. enableRLS's callee object: pgTable(...) call (the base)

Solution Implementation

The fix involves recursively traversing the method chain to find the underlying pgTable call, regardless of how many intermediate method calls are chained.

Testing

before

in cli

ss 3912

after

https://liam-app-git-feature-github-drizzle-method-chaini-3328d9-liambx.vercel.app/erd/p/github.com/kanbn/kan/tree/main/packages/db/src/schema

ss 3898

Impact

This change ensures that ERD visualizations from GitHub folder URLs correctly display all tables from Drizzle schemas, regardless of the method chaining patterns used. Users can now use complex patterns like .enableRLS().$comment() without losing tables in their ERD diagrams.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Parser now supports chained pgTable definitions (e.g., with RLS and table comments).
    • Recognizes comment-based table declarations and schema-qualified tables.
    • Adds parsing support for PostgreSQL enums.
  • Refactor
    • Reworked parser flow for improved modularity and extensibility, enhancing handling of Drizzle Postgres constructs.
  • Tests
    • Added tests covering chained pgTable usage with RLS and comments, including mixed scenarios, to ensure correct table detection, comments, and column types.

Enhance the Drizzle PostgreSQL parser to correctly handle complex method
chaining patterns like `.enableRLS().$comment()`. Previously, only simple
chaining patterns were supported.

Key changes:
- Add extractPgTableFromChain() function to recursively traverse method chains
- Enhance parsePgTableWithComment() to handle nested method calls
- Refactor mainParser for better separation of concerns
- Add comprehensive tests for .enableRLS() and .$comment() method chaining
- Add test for complex chaining combining multiple methods
- Make isPgTableCall private as it's only used internally

This fixes GitHub folder URL parsing for Drizzle schemas that use
multiple method chaining patterns, ensuring all tables are correctly
parsed and displayed in ERD visualizations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
Copy link

changeset-bot bot commented Sep 26, 2025

🦋 Changeset detected

Latest commit: f7cc245

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@liam-hq/schema Patch
@liam-hq/cli Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Contributor

coderabbitai bot commented Sep 26, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds support for parsing chained pgTable calls (e.g., .enableRLS(), .$comment()) and schema/comment/enum pathways by refactoring the Postgres main parser into modular handlers, introducing chain extraction utilities, updating table parsing to use chain-aware logic, and adding tests covering enableRLS and $comment chaining.

Changes

Cohort / File(s) Summary
Postgres parser core
frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts, frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts, frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts
- astUtils: internalizes isPgTableCall; adds exported extractPgTableFromChain to unwrap chained calls to a base pgTable(...).
- mainParser: refactors variable-declarator handling into modular handlers for pgTable, pgEnum, schema tables, and comment-based definitions; imports and uses chain extraction; tracks tables, enums, and variable-to-table mapping.
- tableParser: switches comment parsing to use chain extraction for complex chains; simplifies control flow to return after parsing base table and applying comment.
Tests
frontend/packages/schema/src/parser/drizzle/postgres/__tests__/index.test.ts
Adds tests for chained .enableRLS() and .$comment() on pgTable definitions, including mixed chaining, asserting table count, existence, types, and comments.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Dev as Source File
  participant MP as mainParser
  participant H as Handlers
  participant AU as astUtils
  participant TP as tableParser
  participant State as Parser State

  Dev->>MP: visitVariableDeclarator(node)
  MP->>H: dispatch(callExpr)
  alt pgTable direct or chained
    H->>AU: extractPgTableFromChain(callExpr)
    AU-->>H: basePgTableCall | null
    alt basePgTableCall
      H->>TP: parsePgTableCall(basePgTableCall)
      TP-->>H: DrizzleTableDefinition
      H->>State: save table + var→table map
    else not pgTable
      H-->>MP: pass
    end
  else schema-qualified table
    H->>TP: parseSchemaTableCall(callExpr)
    TP-->>H: DrizzleTableDefinition
    H->>State: save table + map
  else comment-based definition
    H->>TP: parsePgTableWithComment(memberCall)
    TP-->>H: DrizzleTableDefinition
    H->>State: save/update table
  else pgEnum
    H->>TP: parsePgEnumCall(callExpr)
    TP-->>H: DrizzleEnumDefinition
    H->>State: save enum
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

Review effort 3/5

Suggested reviewers

  • hoshinotsuyoshi
  • junkisai
  • NoritakaIkeda

Poem

I nibble on schemas, hop through the chain,
enableRLS? comment?—all clear in my brain.
From enums to tables, I follow the trail,
Unwrapping the calls by twitch of a tail.
Parsers refactored—thump-thump, hooray! 🥕
The carrots of clarity brighten my day.

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title Check ⚠️ Warning The title correctly highlights the main change—adding support for complex Drizzle method chaining—but it is cluttered by the “【⚠️ Do Not Merge】” prefix and an emoji, which adds noise and contradicts the guideline for concise, clear titles. Please remove the “【⚠️ Do Not Merge】” prefix and emoji so the title reads simply “fix(schema): support complex Drizzle method chaining patterns,” clearly summarizing the change without extraneous elements.
Description Check ⚠️ Warning The PR description includes an Issue section and detailed summary and technical details, but it does not follow the repository template’s required structure: the Issue block uses “- fix:” instead of “- resolve:”, and there is no “## Why is this change needed?” heading providing a concise rationale. Consequently the description is missing the mandated template sections and exact headings. Please update the PR description to match the repository template by changing the Issue entry to “- resolve: ” and adding a “## Why is this change needed?” section with a brief explanation of why these parser enhancements are required.
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

vercel bot commented Sep 26, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
liam-app Ready Ready Preview Comment Sep 29, 2025 11:46am
liam-assets Ready Ready Preview Comment Sep 29, 2025 11:46am
liam-erd-sample Ready Ready Preview Comment Sep 29, 2025 11:46am
liam-storybook Ready Ready Preview Comment Sep 29, 2025 11:46am
1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
liam-docs Ignored Ignored Preview Sep 29, 2025 11:46am

Copy link

supabase bot commented Sep 26, 2025

Updates to Preview Branch (feature/github-drizzle-method-chaining-support) ↗︎

Deployments Status Updated
Database Mon, 29 Sep 2025 11:43:26 UTC
Services Mon, 29 Sep 2025 11:43:26 UTC
APIs Mon, 29 Sep 2025 11:43:26 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Mon, 29 Sep 2025 11:43:29 UTC
Migrations Mon, 29 Sep 2025 11:43:29 UTC
Seeding Mon, 29 Sep 2025 11:43:29 UTC
Edge Functions Mon, 29 Sep 2025 11:43:29 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

Copy link
Contributor

github-actions bot commented Sep 26, 2025

🤖 Agent Deep Modeling Execution

Processing time: 9m 16s
Started at: 2025-09-29 11:46:01 UTC

Command Output

@liam-hq/[email protected] execute-deep-modeling /home/runner/work/liam/liam/frontend/internal-packages/agent
pnpm test:integration src/createGraph.integration.test.ts

@liam-hq/[email protected] test:integration /home/runner/work/liam/liam/frontend/internal-packages/agent
vitest --watch=false --passWithNoTests --config vitest.config.integration.ts src/createGraph.integration.test.ts

RUN v3.2.4 /home/runner/work/liam/liam/frontend/internal-packages/agent

(node:7679) ExperimentalWarning: WASI is an experimental feature and might change at any time
(Use node --trace-warnings ... to show where the warning was created)

✅ [INFO] 2025-09-29T11:46:04.228Z
LangSmith Trace URL: https://smith.langchain.com/o/eed4d2d8-0bd8-4ca4-a452-4da88ef63fd6/projects/p/99d63e81-58f9-475a-8985-409b591e26b0?searchModel=%7B%22filter%22%3A%22and(eq(is_root%2C%20true)%2C%20and(eq(metadata_key%2C%20%5C%22thread_id%5C%22)%2C%20eq(metadata_value%2C%20%5C%22fb9e7f51-0607-4a1b-b15e-4014bf8d180a%5C%22)))%22%7D

⏺ (pm):

Planned actions:

  • Interpret “simple user management system” into clear goals, scope, and success criteria.
  • Define personas and core use cases.
  • Translate needs into structured, testable functional requirements grouped by category.
  • Outline key entities and business rules to guide database design.
  • Capture assumptions, risks, and non-functional expectations for delivery and QA.
  • Prepare artifact and save requirements for downstream DB and QA agents.

🔧 saveRequirementsToArtifactTool(businessRequirement: "Deliver a simple,...", functionalRequirements: {12 keys}) ⎿ unknown: Requirements saved successfully to artifact

🔧 schemaDesignTool(operations: [19 items])

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

🔧 saveTestcase(testcaseWithDml: {6 keys})

⏺ (qa):

Success. The test case has been generated and saved.

Here is the test case created for the requirement.

Test case details:

  • Type: functional
  • Category: Security & Compliance
  • Requirement: The system shall prevent reuse of the last 5 passwords during reset.
  • Title: Enforce last-5 password history during reset (positive and negative scenarios)
  • Description: This test creates an organization and a user, populates five password history records (one current) to reflect a realistic password history, and validates that a password reset cannot reuse any of the last five passwords. It includes a positive path where a new, unused password is accepted and added to history, plus a negative path where a reset using a previously used password is rejected. All UUIDs are generated with gen_random_uuid().

DML (produces a self-contained test in PostgreSQL using a PL/pgSQL DO block):
DO $$
DECLARE
v_org_id uuid;
v_user_id uuid;
v_now timestamptz := now();
v_last5 text[];
v_candidate_good text;
v_candidate_bad text := 'hist_hash_3';
v_prev_current_id uuid;
v_prev_current_hash text;
v_new_history_id uuid;
BEGIN
-- Create organization
v_org_id := gen_random_uuid();
INSERT INTO organizations (id, name, created_at, updated_at)
VALUES (v_org_id, 'Test Org', v_now, v_now);

-- Create user
v_user_id := gen_random_uuid();
INSERT INTO users (
id, email, status, created_at, updated_at,
password_hash, email_verified, organization_id,
consent_marketing, failed_login_attempts
) VALUES (
v_user_id, '[email protected]', 'active', v_now, v_now,
'hash_initial', true, v_org_id,
true, 0
);

-- Create 5 password history records: hist_hash_5 is current
INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
VALUES (gen_random_uuid(), 'reset', v_user_id, v_now, 'hist_hash_5', TRUE);

INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
VALUES (gen_random_uuid(), 'reset', v_user_id, v_now - interval '1 day', 'hist_hash_4', FALSE);

INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
VALUES (gen_random_uuid(), 'reset', v_user_id, v_now - interval '2 days', 'hist_hash_3', FALSE);

INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
VALUES (gen_random_uuid(), 'reset', v_user_id, v_now - interval '3 days', 'hist_hash_2', FALSE);

INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
VALUES (gen_random_uuid(), 'reset', v_user_id, v_now - interval '4 days', 'hist_hash_1', FALSE);

-- Build last 5 hashes
SELECT array_agg(password_hash) INTO v_last5
FROM (
SELECT password_hash
FROM password_history
WHERE user_id = v_user_id
ORDER BY created_at DESC
LIMIT 5
) s;

-- Capture current active row for revert
SELECT id, password_hash INTO v_prev_current_id, v_prev_current_hash
FROM password_history
WHERE user_id = v_user_id AND is_active_current = TRUE
LIMIT 1;

-- Generate a good candidate not in last 5
v_candidate_good := md5(gen_random_uuid()::text);

-- Positive test: ensure candidate_good not in last 5
IF v_candidate_good = ANY (v_last5) THEN
RAISE NOTICE 'Positive test: candidate_good unexpectedly in last 5';
ELSE
-- Apply reset
UPDATE users SET password_hash = v_candidate_good, updated_at = v_now WHERE id = v_user_id;
UPDATE password_history SET is_active_current = FALSE WHERE user_id = v_user_id;
INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
VALUES (gen_random_uuid(), 'reset', v_user_id, v_now, v_candidate_good, TRUE)
RETURNING id INTO v_new_history_id;
RAISE NOTICE 'Positive test: password reset accepted for candidate_good';
END IF;

-- Revert positive test changes
IF v_new_history_id IS NOT NULL THEN
DELETE FROM password_history WHERE id = v_new_history_id;
UPDATE password_history SET is_active_current = TRUE WHERE id = v_prev_current_id;
UPDATE users SET password_hash = v_prev_current_hash, updated_at = v_now WHERE id = v_user_id;
END IF;

-- Negative test: attempt to reuse hist_hash_3 (present in last 5)
IF v_candidate_bad = ANY (v_last5) THEN
RAISE NOTICE 'Negative test: candidate_bad is in last 5; reset would be rejected';
ELSE
RAISE NOTICE 'Negative test: candidate_bad not in last 5 (unexpected in this setup)';
END IF;

END
$$;

Notes:

  • All UUIDs are generated via gen_random_uuid().
  • The test creates five password_history entries for the test user, with hist_hash_5 as the current password and hist_hash_4, hist_hash_3, hist_hash_2, hist_hash_1 as prior passwords.
  • Positive path: attempts a reset with a new, unused password (not in the last five) and then reverts the state to ensure the initial setup remains for the negative path.
  • Negative path: confirms that attempting to reset using a previously used password (hist_hash_3) is detected as a reuse within the last five passwords.
  • The test uses only standard DML statements and a DO block; it does not rely on application-level logic, and it preserves schema constraints.

🔧 saveTestcase(testcaseWithDml: {6 keys}) ⎿ unknown: 30/46 test cases passed, 16 failed

Test Case: End-user profile view/edit with email re-verification

1. Error: null value in column "user_id" of relation "email_verifications" violates not-null constraint

-- Test Case: 3a624b2e-ef62-40a0-afdb-fbd1152f7237
-- End-user profile view/edit with email re-verification
-- What this DML operation tests
WITH ins_org AS (
  INSERT INTO organizations (id, name, created_at, updated_at, admin_contact_email)
  VALUES (gen_random_uuid(), 'Acme Corporation', NOW(), NOW(), '[email protected]')
  RETURNING id
), ins_user AS (
  INSERT INTO users (
    id, email, phone, status, username, last_name, first_name, created_at, updated_at, last_login_at, lockout_until, password_hash, email_verified, organization_id, consent_marketing, terms_accepted_at, deletion_expires_at, failed_login_attempts, terms_accepted_version
  )
  VALUES (
    gen_random_uuid(),
    '[email protected]',
    NULL,
    'active',
    'jdoe',
    'Doe',
    'John',
    NOW(),
    NOW(),
    NULL,
    NULL,
    'hash_of_password',
    TRUE,
    (SELECT id FROM ins_org),
    TRUE,
    NULL,
    NULL,
    0,
    'v1'
  )
  RETURNING id
), upd_profile AS (
  UPDATE users
  SET first_name = 'Jonathan',
      last_name = 'Doe-Smith',
      username = 'jonnyd',
      phone = '555-123-4567',
      updated_at = NOW()
  WHERE id IN (SELECT id FROM ins_user)
  RETURNING id
), email_change AS (
  UPDATE users
  SET email = '[email protected]', email_verified = FALSE, updated_at = NOW()
  WHERE id IN (SELECT id FROM upd_profile)
  RETURNING id
), ins_ver AS (
  INSERT INTO email_verifications (id, used, user_id, created_at, expires_at, ip_address, token_hash, user_agent)
  SELECT gen_random_uuid(), FALSE, (SELECT id FROM email_change), NOW(), NOW() + INTERVAL '24 hours', '127.0.0.1', 'hash_of_token', 'TestAgent'
  RETURNING id
)
SELECT
  (SELECT id FROM ins_org) AS organization_id,
  (SELECT id FROM ins_user) AS user_id_after_insert,
  (SELECT id FROM upd_profile) AS updated_user_id,
  (SELECT id FROM email_change) AS email_changed_user_id,
  (SELECT id FROM ins_ver) AS verification_id,
  (SELECT first_name FROM users WHERE id = (SELECT id FROM upd_profile)) AS final_first_name,
  (SELECT last_name FROM users WHERE id = (SELECT id FROM upd_profile)) AS final_last_name,
  (SELECT username FROM users WHERE id = (SELECT id FROM upd_profile)) AS final_username,
  (SELECT phone FROM users WHERE id = (SELECT id FROM upd_profile)) AS final_phone,
  (SELECT email FROM users WHERE id = (SELECT id FROM email_change)) AS final_email_after_change,
  (SELECT email_verified FROM users WHERE id = (SELECT id FROM email_change)) AS email_verified_after_change

Test Case: Enable per-user MFA configuration with TOTP and SMS for two users

1. Error: column "type" is of type mfa_type but expression is of type text

-- Test Case: b28e6f4d-de63-47b6-b116-a08871747128
-- Enable per-user MFA configuration with TOTP and SMS for two users
-- Insert initial organization and two users, then configure MFA: a TOTP config for Alice and an SMS config for Bob.
WITH org AS (
  INSERT INTO organizations (id, name, created_at, updated_at)
  VALUES (gen_random_uuid(), 'Acme Corp', now(), now())
  RETURNING id
),
user1 AS (
  INSERT INTO users (id, email, status, created_at, updated_at, password_hash, email_verified, organization_id, failed_login_attempts, consent_marketing)
  VALUES (gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash_alice', true, (SELECT id FROM org), 0, true)
  RETURNING id
),
user2 AS (
  INSERT INTO users (id, email, status, created_at, updated_at, password_hash, email_verified, organization_id, failed_login_attempts, consent_marketing)
  VALUES (gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash_bob', true, (SELECT id FROM org), 0, true)
  RETURNING id
)
INSERT INTO mfa_configs (id, type, config, enabled, user_id, created_at)
SELECT gen_random_uuid(), 'totp', jsonb_build_object('secret','JBSWY3DPEHPK3PXP','issuer','Acme','digits',6,'period',30), true, (SELECT id FROM user1), now()
UNION ALL
SELECT gen_random_uuid(), 'sms', jsonb_build_object('provider','twilio'), true, (SELECT id FROM user2), now()

Test Case: Sign-out invalidates all active sessions for a user (functional test)

1. Error: column "revoked_at" is of type timestamp with time zone but expression is of type text

-- Test Case: 64971d5e-2524-4237-a8f8-9441f4d970d1
-- Sign-out invalidates all active sessions for a user (functional test)
-- This script creates an organization and two users (Alice and Bob). It issues two active sessions for Alice and one for Bob. It then performs sign-out by revoking Alice's active sessions (the core functional check). It verifies that Alice's sessions are revoked, inspects the resulting session records, checks edge case where Bob has an existing session, includes a negative case attempting to revoke sessions for a non-existent user, and finally tests idempotency by signing out Alice again (no active sessions left). All UUIDs are generated with gen_random_uuid().
-- Setup: Organization and users with active sessions
WITH org AS (
  INSERT INTO organizations (id, name, created_at, updated_at, admin_contact_email)
  VALUES (gen_random_uuid(), 'Acme Corp', now(), now(), '[email protected]')
  RETURNING id AS org_id
), u1 AS (
  INSERT INTO users (id, email, status, created_at, updated_at, organization_id, password_hash, email_verified, failed_login_attempts, consent_marketing)
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), org.org_id, 'hash_alice_pwd', true, 0, true
  FROM org
  RETURNING id AS user1_id
), u2 AS (
  INSERT INTO users (id, email, status, created_at, updated_at, organization_id, password_hash, email_verified, failed_login_attempts, consent_marketing)
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), org.org_id, 'hash_bob_pwd', true, 0, true
  FROM org
  RETURNING id AS user2_id
)
INSERT INTO sessions (id, revoked, user_id, remember, created_at, expires_at, ip_address, revoked_at, token_hash, user_agent, last_accessed_at)
SELECT gen_random_uuid(), false, user1_id, false, now(), now() + interval '30 days', '192.0.2.1', NULL, 'tokenhash_alice_1', 'ua-alice', now()
FROM u1
UNION ALL
SELECT gen_random_uuid(), false, user1_id, true, now(), now() + interval '60 days', '198.51.100.2', NULL, 'tokenhash_alice_2', 'ua-alice-2', now()
FROM u1
UNION ALL
SELECT gen_random_uuid(), false, user2_id, false, now(), now() + interval '15 days', '203.0.113.3', NULL, 'tokenhash_bob_1', 'ua-bob', now()
FROM u2

Test Case: RBAC Admin Capabilities: Manage users and role assignments (create/view/update/deactivate/reactivate/delete; assign/revoke roles)

1. Error: column reference "role_id" is ambiguous

-- Test Case: 1e66d414-b2b7-44cc-bda7-df79e3caeb6b
-- RBAC Admin Capabilities: Manage users and role assignments (create/view/update/deactivate/reactivate/delete; assign/revoke roles)
-- This script performs end-to-end admin user management workflow: creates an organization, an Admin user, and an Admin role with a full permission set; assigns the Admin role to the Admin and then assigns the same Admin role to a regular user. It validates create, update, deactivate/reactivate, and delete flows, as well as role assignment and revocation. It uses gen_random_uuid() for all IDs and demonstrates soft and hard delete steps, ensuring referential consistency across users, roles, permissions, and user_roles.
DO $$
DECLARE
  org_id uuid := gen_random_uuid();
  admin_id uuid := gen_random_uuid();
  role_id uuid := gen_random_uuid();
  regular_id uuid := gen_random_uuid();
  p_create uuid := gen_random_uuid();
  p_read uuid := gen_random_uuid();
  p_update uuid := gen_random_uuid();
  p_delete uuid := gen_random_uuid();
  p_assign uuid := gen_random_uuid();
  p_revoke uuid := gen_random_uuid();
BEGIN
  -- Organization creation
  INSERT INTO organizations(id, name, created_at, updated_at, admin_contact_email)
  VALUES (org_id, 'Acme Organization', now(), now(), '[email protected]');

  -- Admin user creation
  INSERT INTO users(id, email, status, password_hash, email_verified, organization_id, created_at, updated_at, failed_login_attempts, consent_marketing, first_name, last_name)
  VALUES (admin_id, '[email protected]', 'active', 'hash_admin', true, org_id, now(), now(), 0, true, NULL, NULL);

  -- Admin role creation
  INSERT INTO roles(id, name, builtin, created_at, organization_id, description, display_name)
  VALUES (role_id, 'Admin', false, now(), org_id, 'Organization administrator with full access', 'Organization Admin');

  -- Assign admin role to admin user
  INSERT INTO user_roles(id, user_id, role_id, assigned_at, assigned_by)
  VALUES ( gen_random_uuid(), admin_id, role_id, now(), NULL);

  -- Create permissions for the organization
  INSERT INTO permissions(id, name, created_at, description, organization_id)
  VALUES (p_create, 'users.create', now(), 'Create users', org_id),
         (p_read, 'users.read', now(), 'Read user data', org_id),
         (p_update, 'users.update', now(), 'Update user data', org_id),
         (p_delete, 'users.delete', now(), 'Delete users', org_id),
         (p_assign, 'roles.assign', now(), 'Assign roles', org_id),
         (p_revoke, 'roles.revoke', now(), 'Revoke roles', org_id);

  -- Link Admin role to permissions
  INSERT INTO role_permissions(id, role_id, permission_id, created_at)
  SELECT gen_random_uuid(), role_id, p_create, now()
  UNION ALL SELECT gen_random_uuid(), role_id, p_read, now()
  UNION ALL SELECT gen_random_uuid(), role_id, p_update, now()
  UNION ALL SELECT gen_random_uuid(), role_id, p_delete, now()
  UNION ALL SELECT gen_random_uuid(), role_id, p_assign, now()
  UNION ALL SELECT gen_random_uuid(), role_id, p_revoke, now();

  -- Regular user creation
  regular_id := gen_random_uuid();
  INSERT INTO users(id, email, status, password_hash, email_verified, organization_id, created_at, updated_at, failed_login_attempts, consent_marketing, first_name, last_name)
  VALUES (regular_id, '[email protected]', 'active', 'hash_user', true, org_id, now(), now(), 0, true, 'Jane', 'Doe');

  -- Assign Admin role to regular user
  INSERT INTO user_roles(id, user_id, role_id, assigned_at, assigned_by)
  VALUES (gen_random_uuid(), regular_id, role_id, now(), admin_id);

  -- View hints (informative)
  RAISE NOTICE 'Created admin_id=% regular_id=% role_id=%', admin_id, regular_id, role_id;

  -- Admin updates regular user's profile
  UPDATE users
  SET first_name = 'Jane', last_name = 'Doe', email = '[email protected]', updated_at = now()
  WHERE id = regular_id;

  -- Deactivate the regular user
  UPDATE users
  SET status = 'deactivated', deleted_at = now(), updated_at = now()
  WHERE id = regular_id;

  -- Reactivate the regular user
  UPDATE users
  SET status = 'active', updated_at = now(), deleted_at = NULL
  WHERE id = regular_id;

  -- Revoke role from regular user
  DELETE FROM user_roles WHERE user_id = regular_id AND role_id = role_id;

  -- Soft delete the regular user
  UPDATE users
  SET status = 'deleted', deleted_at = now(), updated_at = now()
  WHERE id = regular_id;

  -- Hard delete: remove mappings and user record
  DELETE FROM user_roles WHERE user_id = regular_id;
  DELETE FROM users WHERE id = regular_id;
END
$$ LANGUAGE plpgsql

Test Case: Support multiple roles per user within a single organization

1. Error: column "id" does not exist

-- Test Case: 21c00d37-a2db-4167-b412-e009d8f21a65
-- Support multiple roles per user within a single organization
-- Tests that a user can hold multiple roles by creating three distinct roles for the same organization and assigning them to a single user. Includes validation queries to confirm all three roles are associated with the user and that the roles are correctly retrieved. Also exercises an expiration on one of the role assignments to test boundary handling.
WITH org AS (
  INSERT INTO organizations (id, name, created_at, updated_at, admin_contact_email)
  VALUES (gen_random_uuid(), 'Acme Org LLC', now(), now(), NULL)
  RETURNING id AS org_id
), admin_role AS (
  INSERT INTO roles (id, name, builtin, created_at, organization_id)
  VALUES (gen_random_uuid(), 'Admin', true, now(), (SELECT org_id FROM org))
  RETURNING id AS admin_role_id
), member_role AS (
  INSERT INTO roles (id, name, builtin, created_at, organization_id)
  VALUES (gen_random_uuid(), 'Member', false, now(), (SELECT org_id FROM org))
  RETURNING id AS member_role_id
), guest_role AS (
  INSERT INTO roles (id, name, builtin, created_at, organization_id)
  VALUES (gen_random_uuid(), 'Guest', false, now(), (SELECT org_id FROM org))
  RETURNING id AS guest_role_id
), new_user AS (
  INSERT INTO users (id, email, status, password_hash, email_verified, organization_id, created_at, updated_at, failed_login_attempts, consent_marketing)
  VALUES (gen_random_uuid(), '[email protected]', 'pending_verification'::user_status, 'hash_of_password', true, (SELECT org_id FROM org), now(), now(), 0, true)
  RETURNING id AS user_id
)
INSERT INTO user_roles (id, role_id, user_id, assigned_at, assigned_by, note, expires_at)
SELECT gen_random_uuid(), (SELECT admin_role_id FROM admin_role), (SELECT id FROM new_user), now(), NULL, NULL, NULL
UNION ALL
SELECT gen_random_uuid(), (SELECT member_role_id FROM member_role), (SELECT id FROM new_user), now(), NULL, NULL, NULL
UNION ALL
SELECT gen_random_uuid(), (SELECT guest_role_id FROM guest_role), (SELECT id FROM new_user), now(), NULL, NULL, now() + interval '7 days'

Test Case: Password reset initiation: single-use link valid for 1 hour (positive and negative flows)

1. Error: function digest(unknown, unknown) does not exist

-- Test Case: 1230edd1-6c88-41c7-827c-8ca35d48399b
-- Password reset initiation: single-use link valid for 1 hour (positive and negative flows)
-- Set up a fresh organization and user; emit a valid password reset (expires in 1 hour); record an expired reset as boundary data; emit another reset to_be_used; then mark the valid reset as used and verify that no active resets remain. This validates that resets are single-use and expire after the defined window.
-- Ensure required extension for token hashing


-- 1) Setup: Create an organization and a user to own the password reset flow
WITH
  ins_org AS (
    INSERT INTO organizations (id, name, created_at, updated_at, deleted_at, admin_contact_email)
    VALUES (gen_random_uuid(), 'Acme Corp', now(), now(), NULL, '[email protected]')
    RETURNING id AS org_id
  ),
  ins_user AS (
    INSERT INTO users (
      id, email, status, password_hash, email_verified, organization_id,
      consent_marketing, created_at, updated_at, failed_login_attempts
    )
    VALUES (
      gen_random_uuid(), '[email protected]', 'active', digest('P@ssw0rd', 'sha256'), true,
      (SELECT org_id FROM ins_org),
      true, now(), now(), 0
    )
    RETURNING id AS user_id
  ),
  reset_valid AS (
    INSERT INTO password_resets (id, used, user_id, created_at, expires_at, ip_address, token_hash)
    VALUES (
      gen_random_uuid(), false, (SELECT user_id FROM ins_user), now(),
      now() + interval '1 hour', '203.0.113.1', digest('reset-' || gen_random_uuid()::text, 'sha256')
    )
    RETURNING id AS valid_id
  ),
  reset_expired AS (
    INSERT INTO password_resets (id, used, user_id, created_at, expires_at, ip_address, token_hash)
    VALUES (
      gen_random_uuid(), false, (SELECT user_id FROM ins_user), now() - interval '2 hours',
      now() - interval '1 hour', '203.0.113.2', digest('expired-' || gen_random_uuid()::text, 'sha256')
    )
    RETURNING id AS expired_id
  ),
  reset_to_be_used AS (
    INSERT INTO password_resets (id, used, user_id, created_at, expires_at, ip_address, token_hash)
    VALUES (
      gen_random_uuid(), false, (SELECT user_id FROM ins_user), now(),
      now() + interval '2 hours', '203.0.113.3', digest('to_be_used-' || gen_random_uuid()::text, 'sha256')
    )
    RETURNING id AS to_be_used_id
  ),
  do_update AS (
    UPDATE password_resets
    SET used = true
    WHERE id = (SELECT valid_id FROM reset_valid)
    RETURNING id
  ),
  active_check AS (
    SELECT COUNT(*) AS active_count
    FROM password_resets
    WHERE user_id = (SELECT user_id FROM ins_user)
      AND used = false
      AND expires_at > now()
  ),
  sel AS (
    SELECT
      (SELECT valid_id FROM reset_valid) AS valid_reset_id_before_use,
      (SELECT expired_id FROM reset_expired) AS expired_reset_id,
      (SELECT to_be_used_id FROM reset_to_be_used) AS to_be_used_reset_id,
      (SELECT active_count FROM active_check) AS active_resets_after_use
  )
SELECT * FROM sel

Test Case: Bulk admin actions: invite and deactivate/reactivate up to 100 users

1. Error: column "status" is of type user_status but expression is of type text

-- Test Case: 2bb3d52c-df2f-465b-9eee-25fdf2da1356
-- Bulk admin actions: invite and deactivate/reactivate up to 100 users
-- Set up a test organization, create 12 users, bulk-invite 12 emails, deactivate 8 users, then reactivate 4 specific users; verify counts and final states.
WITH org AS (
  INSERT INTO organizations (id, name, created_at, updated_at)
  VALUES (gen_random_uuid(), 'Bulk Admin Org', now(), now())
  RETURNING id
), wall AS (
  INSERT INTO users (
    id, email, status, created_at, updated_at, password_hash, email_verified, organization_id, consent_marketing, failed_login_attempts
  )
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash1', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash2', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash3', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash4', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash5', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash6', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash7', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash8', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash9', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash10', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash11', true, (SELECT id FROM org), true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash12', true, (SELECT id FROM org), true, 0
  RETURNING email, id
), deactivated AS (
  UPDATE users
  SET status = 'deactivated', updated_at = now()
  WHERE organization_id = (SELECT id FROM org)
  AND email IN (
    SELECT email FROM wall
    ORDER BY email
    LIMIT 8
  )
  RETURNING id, email
), invited AS (
  INSERT INTO invites (id, email, status, created_at, expires_at, inviter_id, token_hash, accepted_at, organization_id)
  SELECT gen_random_uuid(), v.email, 'pending', now(), now() + interval '1 day', (SELECT id FROM wall LIMIT 1), md5(gen_random_uuid()::text), NULL, (SELECT id FROM org)
  FROM (VALUES ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]'), ('[email protected]')) AS v(email)
  RETURNING id
), reactivated AS (
  UPDATE users
  SET status = 'active', updated_at = now()
  WHERE organization_id = (SELECT id FROM org)
  AND email IN ('[email protected]','[email protected]','[email protected]','[email protected]')
  RETURNING id, email
)
SELECT
  (SELECT count(*) FROM wall) AS total_users_created,
  (SELECT count(*) FROM deactivated) AS total_deactivated_after_bulk,
  (SELECT count(*) FROM invited) AS total_invites_created,
  (SELECT count(*) FROM reactivated) AS total_reactivated,
  (SELECT count(*) FROM users WHERE organization_id=(SELECT id FROM org) AND status='active') AS final_active_count,
  (SELECT count(*) FROM users WHERE organization_id=(SELECT id FROM org) AND status='deactivated') AS final_deactivated_count,
  (SELECT id FROM org) AS org_id

Test Case: Admin force-logout: revoke all active sessions for a targeted user and verify with edge-case for user with no sessions

1. Error: column "expires_at" is of type timestamp with time zone but expression is of type text

-- Test Case: 04b03794-c141-4ccb-9395-e3937a20609d
-- Admin force-logout: revoke all active sessions for a targeted user and verify with edge-case for user with no sessions
-- This DML sequence creates an organization, two users (one with multiple sessions and one with no sessions), creates two sessions for the first user, revokes all sessions for the first user (admin force-logout), and attempts revocation for the second user (no sessions). The final result shows the revoked status of all sessions for both users, validating both the positive and edge-case scenarios.
WITH ins_org AS (
  INSERT INTO organizations (id, name, created_at, updated_at, admin_contact_email)
  VALUES (gen_random_uuid(), 'Acme Org', now(), now(), '[email protected]')
  RETURNING id AS org_id
), ins_user AS (
  INSERT INTO users (
    id, email, phone, status, username, last_name, created_at, deleted_at, updated_at,
    first_name, last_login_at, lockout_until, password_hash, email_verified,
    organization_id, consent_marketing, terms_accepted_at, deletion_expires_at,
    failed_login_attempts, terms_accepted_version
  )
  SELECT gen_random_uuid(), '[email protected]', NULL, 'active', 'alice', 'Anderson', now(), NULL, now(),
         'Alice', NULL, NULL, 'hash_of_password', true,
         (SELECT org_id FROM ins_org), true, now(), NULL, 0, 'v1.0'
  FROM ins_org
  RETURNING id AS user1_id
), ins_user_no_sessions AS (
  INSERT INTO users (
    id, email, phone, status, username, last_name, created_at, updated_at,
    password_hash, email_verified,
    organization_id, consent_marketing
  )
  SELECT gen_random_uuid(), '[email protected]', NULL, 'active', NULL, NULL, now(), now(),
         'hash3', true,
         (SELECT org_id FROM ins_org), true
  FROM ins_org
  RETURNING id AS user2_id
), ins_sessions AS (
  INSERT INTO sessions (id, revoked, user_id, remember, created_at, expires_at, ip_address, revoked_at, token_hash, user_agent, last_accessed_at)
  SELECT gen_random_uuid(), false, (SELECT user1_id FROM ins_user), true, now(), NULL, '192.0.2.1', NULL, 'tokhash1', 'UnitTestAgent', NULL
  FROM ins_user
  UNION ALL
  SELECT gen_random_uuid(), false, (SELECT user1_id FROM ins_user), false, now(), NULL, '203.0.113.5', NULL, 'tokhash2', 'UnitTestAgent', NULL
  FROM ins_user
), revoke1 AS (
  UPDATE sessions
  SET revoked = TRUE, revoked_at = now()
  WHERE user_id IN (SELECT user1_id FROM ins_user)
  RETURNING id
), revoke2 AS (
  UPDATE sessions
  SET revoked = TRUE, revoked_at = now()
  WHERE user_id IN (SELECT user2_id FROM ins_user_no_sessions)
  RETURNING id
)
SELECT 'user1' AS user_label, s.id, s.revoked, s.revoked_at
FROM sessions s
WHERE s.user_id IN (SELECT user1_id FROM ins_user)
UNION ALL
SELECT 'user2' AS user_label, s.id, s.revoked, s.revoked_at
FROM sessions s
WHERE s.user_id IN (SELECT user2_id FROM ins_user_no_sessions)
ORDER BY user_label, id

Test Case: Filters by status and role with sorting by created_at or last_login_at

1. Error: column "expires_at" is of type timestamp with time zone but expression is of type text

-- Test Case: e130c3df-dd8a-4a4d-8b78-2741edcac630
-- Filters by status and role with sorting by created_at or last_login_at
-- What this DML operation tests: creates an organization, Admin and User roles, three users with varying statuses and last_login_at times, assigns Admin role to Alice and User role to Bob, and then runs queries to verify (a) filtering by status and role returns expected users, (b) sorting by created_at yields correct order, (c) sorting by last_login_at puts most recently active first, and (d) negative case returns no rows for a non-existent combination.
WITH
  org AS (
    INSERT INTO organizations (id, name, created_at, updated_at)
    VALUES (gen_random_uuid(), 'Acme Corp', now(), now())
    RETURNING id AS org_id
  ),
  admin_role AS (
    INSERT INTO roles (id, name, builtin, created_at, description, display_name, organization_id)
    VALUES (gen_random_uuid(), 'admin', true, now(), 'System administrator', 'Admin', (SELECT org_id FROM org))
    RETURNING id AS admin_role_id
  ),
  user_role AS (
    INSERT INTO roles (id, name, builtin, created_at, description, display_name, organization_id)
    VALUES (gen_random_uuid(), 'user', false, now(), 'Regular user', 'User', (SELECT org_id FROM org))
    RETURNING id AS user_role_id
  ),
  users AS (
    INSERT INTO users (
      id, email, phone, status, username, last_name, first_name, created_at, updated_at, last_login_at, lockout_until,
      password_hash, email_verified, organization_id, consent_marketing, terms_accepted_at, deletion_expires_at, failed_login_attempts, terms_accepted_version
    )
    VALUES
      (gen_random_uuid(), '[email protected]', NULL, 'active', NULL, 'Admin', 'Alice', now() - interval '0 days', now(), now(), NULL,
       'hash-alice', TRUE, (SELECT org_id FROM org), TRUE, NULL, NULL, 0, NULL),
      (gen_random_uuid(), '[email protected]', NULL, 'active', NULL, 'User', 'Bob', now() - interval '1 day', now(), now() - interval '1 day', NULL,
       'hash-bob', TRUE, (SELECT org_id FROM org), TRUE, NULL, NULL, 0, NULL),
      (gen_random_uuid(), '[email protected]', NULL, 'pending_verification', NULL, 'User', 'Carol', now() - interval '2 days', now(), NULL, NULL,
       'hash-carol', TRUE, (SELECT org_id FROM org), TRUE, NULL, NULL, 0, NULL)
    RETURNING id, email
  ),
  user_roles_assignments AS (
    INSERT INTO user_roles (id, note, role_id, user_id, expires_at, assigned_at, assigned_by)
    SELECT gen_random_uuid(), NULL, (SELECT admin_role_id FROM admin_role), (SELECT id FROM users WHERE email='[email protected]'), NULL, NOW(), NULL
    UNION ALL
    SELECT gen_random_uuid(), NULL, (SELECT user_role_id FROM user_role), (SELECT id FROM users WHERE email='[email protected]'), NULL, NOW(), NULL
    RETURNING *
  )
SELECT 1

Test Case: Export current user list with applied filters to CSV (positive and negative tests) ensuring secrets are excluded

1. Error: null value in column "actor_id" of relation "exports" violates not-null constraint

-- 3) Create an export record representing a CSV export with filters (Org A, active, email_verified)
INSERT INTO exports (id, status, columns, filters, actor_id, created_at, completed_at, requestor_ip, file_location)
VALUES (
  gen_random_uuid(),
  'completed',
  ARRAY['id','email','status','organization_id','created_at','updated_at','email_verified']::text[],
  jsonb_build_object('organization_id', (SELECT id FROM organizations WHERE name='Org A'), 'status', 'active', 'email_verified', true),
  NULL,
  now(),
  now(),
  '127.0.0.1',
  '/exports/user_export_orgA_active.csv'
)

Test Case: Audit log of export initiator and timestamp (who and when)

1. Error: column "status" is of type export_status but expression is of type text

-- Test Case: fe0f1024-46d9-45d8-a7e8-2dfce6534908
-- Audit log of export initiator and timestamp (who and when)
-- Populate org and two admins; create two valid export logs initiated by different admins; attempt a log with an invalid actor to exercise FK-violation handling; verify via a join that initiators map to the correct emails and timestamps.
WITH ins_org AS (
  INSERT INTO organizations (id, name, created_at, updated_at, admin_contact_email)
  VALUES (gen_random_uuid(), 'Globex Corp', now(), now(), '[email protected]')
  RETURNING id AS org_id
), admin1 AS (
  INSERT INTO users (id, email, status, password_hash, email_verified, organization_id, consent_marketing, created_at, updated_at, failed_login_attempts)
  SELECT gen_random_uuid(), '[email protected]', 'active', 'hash1', true, (SELECT org_id FROM ins_org), true, now(), now(), 0
  RETURNING id AS admin1_id
), admin2 AS (
  INSERT INTO users (id, email, status, password_hash, email_verified, organization_id, consent_marketing, created_at, updated_at, failed_login_attempts)
  SELECT gen_random_uuid(), '[email protected]', 'active', 'hash2', true, (SELECT org_id FROM ins_org), true, now(), now(), 0
  RETURNING id AS admin2_id
)
INSERT INTO exports (id, status, columns, filters, actor_id, created_at)
SELECT gen_random_uuid(), 'pending', ARRAY['reports'], jsonb_build_object('scope', 'self'), (SELECT admin1_id FROM admin1), now()
UNION ALL
SELECT gen_random_uuid(), 'pending', ARRAY['reports'], jsonb_build_object('scope', 'self'), (SELECT admin2_id FROM admin2), now()

Test Case: Transactional Email Triggers - Verification, Reset, Invites, Email Change, Password Change Alerts, Activation/Deactivation

1. Error: INSERT has more expressions than target columns

-- Admin user (active)
INSERT INTO users (id, email, status, organization_id, password_hash, email_verified, created_at, updated_at, failed_login_attempts, phone, username, last_name, first_name, last_login_at, lockout_until, deleted_at, consent_marketing, terms_accepted_at, deletion_expires_at, terms_accepted_version)
SELECT
  gen_random_uuid(),
  '[email protected]',
  'active',
  (SELECT org_id FROM org_tmp),
  'admin_hash',
  TRUE,
  now(),
  now(),
  0,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  TRUE,
  NULL,
  NULL,
  'v1.0'

Test Case: Security alert notifications upon password and MFA changes

1. Error: relation "verified_user" does not exist

-- Password change for verified user: update and history
UPDATE users
SET password_hash = 'hash_changed', updated_at = now()
WHERE id = (SELECT id FROM verified_user)

Test Case: RBAC and Input Validation Coverage: Admin/User RBAC, Permissions, and Validation Scenarios

1. Error: syntax error at or near "("

-- Test Case: b56ad8ac-c02f-4687-8aaf-0831c01cbc7b
-- RBAC and Input Validation Coverage: Admin/User RBAC, Permissions, and Validation Scenarios
-- Sets up a multi-tenant organization with Admin and Viewer roles, assigns permissions, creates admin and regular users with appropriate role mappings, and adds related artifacts (password history, sessions, MFA configs, email verifications, and a negative insert test). Uses gen_random_uuid() for all UUIDs and demonstrates multiple roles per user and input validation edge case.
DO $$(
DECLARE
  org_id UUID;
  admin_role_id UUID;
  viewer_role_id UUID;
  read_perm_id UUID;
  create_perm_id UUID;
  update_perm_id UUID;
  delete_perm_id UUID;
  assign_perm_id UUID;
  admin_user_id UUID;
  regular_user_id UUID;
BEGIN
  -- Create Organization
  INSERT INTO organizations (id, name, created_at, updated_at)
  VALUES (gen_random_uuid(), 'Acme Corp', now(), now())
  RETURNING id INTO org_id;

  -- Create permissions
  INSERT INTO permissions (id, name, created_at, organization_id)
  VALUES (gen_random_uuid(), 'users.read', now(), org_id)
  RETURNING id INTO read_perm_id;

  INSERT INTO permissions (id, name, created_at, organization_id)
  VALUES (gen_random_uuid(), 'users.create', now(), org_id)
  RETURNING id INTO create_perm_id;

  INSERT INTO permissions (id, name, created_at, organization_id)
  VALUES (gen_random_uuid(), 'users.update', now(), org_id)
  RETURNING id INTO update_perm_id;

  INSERT INTO permissions (id, name, created_at, organization_id)
  VALUES (gen_random_uuid(), 'users.delete', now(), org_id)
  RETURNING id INTO delete_perm_id;

  INSERT INTO permissions (id, name, created_at, organization_id)
  VALUES (gen_random_uuid(), 'roles.assign', now(), org_id)
  RETURNING id INTO assign_perm_id;

  -- Create Admin role
  INSERT INTO roles (id, name, builtin, created_at, organization_id, description, display_name)
  VALUES (gen_random_uuid(), 'Admin', true, now(), org_id, 'System administrator with full access', 'Administrator')
  RETURNING id INTO admin_role_id;

  -- Create Viewer role
  INSERT INTO roles (id, name, builtin, created_at, organization_id, description, display_name)
  VALUES (gen_random_uuid(), 'Viewer', false, now(), org_id, 'Read-only access for basic users', 'Viewer')
  RETURNING id INTO viewer_role_id;

  -- Role permissions: Admin gets all
  INSERT INTO role_permissions (id, role_id, permission_id, created_at) VALUES
    (gen_random_uuid(), admin_role_id, read_perm_id, now()),
    (gen_random_uuid(), admin_role_id, create_perm_id, now()),
    (gen_random_uuid(), admin_role_id, update_perm_id, now()),
    (gen_random_uuid(), admin_role_id, delete_perm_id, now()),
    (gen_random_uuid(), admin_role_id, assign_perm_id, now());

  -- Role permissions: Viewer gets read
  INSERT INTO role_permissions (id, role_id, permission_id, created_at) VALUES
    (gen_random_uuid(), viewer_role_id, read_perm_id, now());

  -- Admin User
  INSERT INTO users (id, email, status, created_at, updated_at, password_hash, email_verified, organization_id, consent_marketing, failed_login_attempts)
  VALUES (gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash-admin', true, org_id, true, 0)
  RETURNING id INTO admin_user_id;

  -- Assign Admin role to Admin user
  INSERT INTO user_roles (id, note, role_id, user_id, expires_at, assigned_at, assigned_by)
  VALUES (gen_random_uuid(), 'System initial admin', admin_role_id, admin_user_id, NULL, now(), NULL);

  -- Regular User
  INSERT INTO users (id, email, status, created_at, updated_at, password_hash, email_verified, organization_id, consent_marketing, failed_login_attempts)
  VALUES (gen_random_uuid(), '[email protected]', 'active', now(), now(), 'hash-user', true, org_id, true, 0)
  RETURNING id INTO regular_user_id;

  -- Assign Viewer role to Regular User
  INSERT INTO user_roles (id, note, role_id, user_id, expires_at, assigned_at, assigned_by)
  VALUES (gen_random_uuid(), 'Assigned Viewer', viewer_role_id, regular_user_id, NULL, now(), NULL);

  -- Password history for Admin
  INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
  VALUES (gen_random_uuid(), 'initial', admin_user_id, now(), 'hash-admin', true);

  -- Password history for Regular User
  INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
  VALUES (gen_random_uuid(), 'initial', regular_user_id, now(), 'hash-user', true);

  -- Admin Session
  INSERT INTO sessions (id, revoked, user_id, remember, created_at, expires_at, ip_address, revoked_at, token_hash, user_agent, last_accessed_at)
  VALUES (gen_random_uuid(), false, admin_user_id, true, now(), now() + interval '7 days', '127.0.0.1', NULL, 'token-admin', 'DBTest/DB', now());

  -- MFA for Admin
  INSERT INTO mfa_configs (id, type, config, enabled, user_id, created_at, last_used_at)
  VALUES (gen_random_uuid(), 'totp', '{"secret":"ABCDEF"}'::jsonb, true, admin_user_id, now(), NULL);

  -- Email verification for Admin
  INSERT INTO email_verifications (id, used, user_id, created_at, expires_at, ip_address, token_hash, user_agent)
  VALUES (gen_random_uuid(), false, admin_user_id, now(), now() + interval '1 day', '127.0.0.1', 'tokenhash', 'DBTest/DB');

  -- Negative test: attempt to insert a user with null email to validate input validation path
  BEGIN
    INSERT INTO users (id, email, status, created_at, updated_at, password_hash, email_verified, organization_id, consent_marketing, failed_login_attempts)
    VALUES (gen_random_uuid(), NULL, 'active', now(), now(), 'hash-null', true, org_id, true, 0);
  EXCEPTION WHEN others THEN
    NULL; -- swallow expected NOT NULL violation
  END;

END;
$$

Test Case: Store passwords as salted hashes (bcrypt) and record password history; ensure no plaintext storage

1. Error: function gen_salt(unknown, integer) does not exist

-- Test Case: e63a96df-f410-4249-9daa-be4f286a73e6
-- Store passwords as salted hashes (bcrypt) and record password history; ensure no plaintext storage
-- Insert organization and user with a bcrypt salted password hash, add initial password_history, and verify hashed password storage conforms to bcrypt format and no plaintext is stored.
WITH org AS (
  INSERT INTO organizations (id, name, created_at, updated_at)
  VALUES (gen_random_uuid(), 'Acme Cloud', now(), now())
  RETURNING id
), new_user AS (
  INSERT INTO users (
    id, email, phone, status, username, last_name, created_at, deleted_at, first_name, updated_at, last_login_at, lockout_until, password_hash, email_verified, organization_id, consent_marketing, terms_accepted_at, deletion_expires_at, failed_login_attempts, terms_accepted_version
  )
  SELECT
    gen_random_uuid(),
    '[email protected]',
    NULL,
    'active',
    NULL,
    'Anderson',
    now(),
    NULL,
    'Alice',
    now(),
    NULL,
    NULL,
    crypt('S3cure#Password!', gen_salt('bf', 12)),
    TRUE,
    org.id,
    TRUE,
    NULL,
    NULL,
    0,
    'v1.0'
  FROM org
  RETURNING id, password_hash
)
INSERT INTO password_history (id, source, user_id, created_at, password_hash, is_active_current)
SELECT gen_random_uuid(), 'initial', new_user.id, now(), new_user.password_hash, TRUE
FROM new_user

Test Case: Capture and manage separate consent flag for marketing emails in multi-tenant user management

1. Error: column "status" is of type user_status but expression is of type text

WITH org AS (
  INSERT INTO organizations (id, name, created_at, updated_at)
  VALUES (gen_random_uuid(), 'Acme Corp', now(), now())
  RETURNING id AS org_id
), created AS (
  INSERT INTO users (id, email, password_hash, status, created_at, updated_at, organization_id, email_verified, consent_marketing, failed_login_attempts)
  SELECT gen_random_uuid(), '[email protected]', 'pbkdf2$mock$hash1', 'active', now(), now(), (SELECT org_id FROM org), true, true, 0
  UNION ALL
  SELECT gen_random_uuid(), '[email protected]', 'pbkdf2$mock$hash2', 'active', now(), now(), (SELECT org_id FROM org), true, false, 0
  RETURNING id, email
)
SELECT * FROM created

⏺ (lead):

Concise summary of what was accomplished

  • Main user requirements analyzed

    • User accounts and profiles with secure authentication (email + password; optional MFA)
    • Self-service profile edits; email changes require re-verification
    • RBAC with predefined roles (Admin, Manager, User) and per-organization scope
    • Admin capabilities: user CRUD, role assignments, bulk actions, password resets, forced logouts, activity/role history
    • Account lifecycle: self-registration, email verification, password resets, deactivation/reactivation, soft delete with retention, eventual hard delete
    • Administration, search, reporting, exports; auditing and logging
    • Notifications (transactional emails for verification, resets, invites, etc.)
    • API exposure with RBAC enforcement and input validation
    • Data governance and retention (audit logs, export retention, deletion/hold policies)
    • Security/compliance (password hashing, tokens, MFA, localization)
  • Key database design decisions

    • Multi-tenant architecture: organizations as tenants; users scoped by organization_id
    • RBAC model: roles, permissions, role_permissions, user_roles; support for multiple roles per user; built-in roles
    • Per-organization uniqueness: unique constraints on (organization_id, email) and (organization_id, username)
    • Enumerations for lifecycle/status: user_status, mfa_type, export_status, invite_status
    • Time-based/soft-delete lifecycle: deleted_at and deletion_expires_at for soft deletes with scheduled hard deletes
    • UTC timestamps throughout; IDs generated server-side with UUIDs (gen_random_uuid)
    • Core data structures for security/audit: audit_events; password_history; password_resets; email_verifications; mfa_configs; sessions
    • Support for operational features: exposes exports, invites, terms_acceptances, and action trails
    • Security design choices: hashed passwords, single-use and time-bound tokens, encrypted/minimal MFA config
    • Data governance: audit retention, export watermarking/ownership metadata, retention windows
  • Schemas, tables, or data structures created/modified

    • Enums added: user_status, mfa_type, export_status, invite_status
    • Tables added: organizations, users, password_history, sessions, mfa_configs, email_verifications, password_resets, audit_events, exports, invites, terms_acceptances, roles, permissions, role_permissions, user_roles
    • Indexes and constraints established (e.g., idx_users_email, idx_users_username, idx_users_org_status, idx_sessions_user, unique uq_users_email_org, uq_users_username_org, foreign keys between organizations/users, etc.)
    • Core relationships defined: organization → users; users → roles/permissions via user_roles and role_permissions; audit_logs and exports tied to actors/targets as appropriate
  • Important outcomes or results

    • Schema updates applied and validated (schemaDesignTool: 210/210 statements executed; new version created)
    • A comprehensive suite of functional test cases was created and stored to validate key flows (user creation, uniqueness, statuses, profile edits with email re-verification, MFA, sign-in, remember-me sessions, admin RBAC, audits, exports, invites, password history, soft/hard delete, etc.)
    • Early QA run showed progress: about 30/46 test cases passed; several failures were due to test-case SQL/logic issues rather than schema issues (notably mismatches in how test data aligned with constraints or tokens/types).
    • Overall trajectory: an end-to-end foundation for a secure, multi-tenant user management service with RBAC, auditing, and governance, ready for further refinement and test-hardening.

If you’d like, I can provide a compact mapping of each major requirement to the exact schema component (which table/enum) it supports, or generate a focused test plan targeting any remaining gaps.·

Test Files 1 passed (1)
Tests 1 passed (1)
Start at 11:46:02
Duration 554.45s (transform 489ms, setup 0ms, collect 1.48s, tests 552.68s, environment 0ms, prepare 69ms)

Status: Completed successfully

Finished at: 2025-09-29 11:55:17 UTC

…q/liam into feature/github-drizzle-method-chaining-support
@FunamaYukina
Copy link
Member Author

@coderabbitai review

Copy link
Contributor

coderabbitai bot commented Sep 29, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
frontend/packages/schema/src/parser/drizzle/postgres/__tests__/index.test.ts (2)

61-101: Good coverage for pgTable(...).enableRLS() chains; please add schema.table(...) variants.

Add a case for auth.table('...').enableRLS() to prevent regressions for multi‑schema projects.

Apply a test like:

+    it('should parse schema.table() with .enableRLS() chaining', async () => {
+      const { value } = await processor(`
+        import { pgSchema, serial, text } from 'drizzle-orm/pg-core';
+        const auth = pgSchema('auth');
+        export const users = auth.table('users', {
+          id: serial('id').primaryKey(),
+          email: text('email').notNull(),
+        }).enableRLS();
+      `)
+      expect(Object.keys(value.tables)).toHaveLength(1)
+      expect(value.tables['users']).toBeDefined()
+      expect(value.tables['users']?.columns['email']?.type).toBe('text')
+    })

103-140: Capture comment ordering edge cases in tests.

You cover enableRLS().$comment(); also test $comment().enableRLS() and schema.table(...).$comment().

Proposed additions:

+    it('should parse tables when $comment() is not the last chained call', async () => {
+      const { value } = await processor(`
+        import { pgTable, serial, text } from 'drizzle-orm/pg-core';
+        export const t = pgTable('t', { id: serial('id'), name: text('name') }).$comment('T').enableRLS();
+      `)
+      expect(value.tables['t']?.comment).toBe('T')
+    })
+
+    it('should parse schema.table().$comment()', async () => {
+      const { value } = await processor(`
+        import { pgSchema, serial } from 'drizzle-orm/pg-core';
+        const auth = pgSchema('auth');
+        export const users = auth.table('users', { id: serial('id').primaryKey() }).$comment('Auth users');
+      `)
+      expect(value.tables['users']?.comment).toBe('Auth users')
+    })
frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts (1)

5-9: Import helpers to support generalized chaining and comment extraction.

You’ll need parseMethodChain/getArgumentExpression/isStringLiteral and the generalized extractor.

Apply:

-import type { CallExpression, Module, VariableDeclarator } from '@swc/core'
+import type { CallExpression, Module, VariableDeclarator } from '@swc/core'
 import { parseSync } from '@swc/core'
 import type { Processor, ProcessResult } from '../../types.js'
-import { extractPgTableFromChain, isSchemaTableCall } from './astUtils.js'
+import {
+  extractTableCallFromChain,
+  isSchemaTableCall,
+  parseMethodChain,
+  getArgumentExpression,
+  isStringLiteral,
+} from './astUtils.js'
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b5ca715 and f7a1816.

📒 Files selected for processing (4)
  • frontend/packages/schema/src/parser/drizzle/postgres/__tests__/index.test.ts (1 hunks)
  • frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts (1 hunks)
  • frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts (2 hunks)
  • frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts (2 hunks)
🧰 Additional context used
🧠 Learnings (6)
📓 Common learnings
Learnt from: FunamaYukina
PR: liam-hq/liam#2299
File: frontend/packages/db-structure/src/parser/drizzle/tableParser.ts:0-0
Timestamp: 2025-07-07T06:36:07.240Z
Learning: In the Drizzle ORM parser (frontend/packages/db-structure/src/parser/drizzle/tableParser.ts), the parseIndexDefinition function properly supports parsing index types from .using() method chains like index('name').on(...).using('btree'), extracting the type from the first argument of the .using() call and handling various PostgreSQL index types such as 'btree', 'gin', 'gist', etc.
Learnt from: FunamaYukina
PR: liam-hq/liam#2299
File: frontend/packages/db-structure/src/parser/drizzle/tableParser.ts:0-0
Timestamp: 2025-07-07T06:36:07.240Z
Learning: In the Drizzle ORM parser (frontend/packages/db-structure/src/parser/drizzle/tableParser.ts), the parseIndexDefinition function properly supports parsing index types from .using() method chains like index('name').on(...).using('btree'), extracting the type from the first argument of the .using() call and handling various PostgreSQL index types such as 'btree', 'gin', 'gist', etc.
📚 Learning: 2025-07-14T08:31:33.458Z
Learnt from: FunamaYukina
PR: liam-hq/liam#2556
File: frontend/packages/db-structure/src/parser/drizzle/postgres/mainParser.ts:7-7
Timestamp: 2025-07-14T08:31:33.458Z
Learning: In the drizzle parser refactor, the import path `../../types.js` in `frontend/packages/db-structure/src/parser/drizzle/postgres/mainParser.ts` correctly references `src/parser/types.ts` which exports the `Processor` and `ProcessResult` types. The build succeeds without issues as confirmed by FunamaYukina.

Applied to files:

  • frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/__tests__/index.test.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts
📚 Learning: 2025-07-14T08:31:33.458Z
Learnt from: FunamaYukina
PR: liam-hq/liam#2556
File: frontend/packages/db-structure/src/parser/drizzle/postgres/mainParser.ts:7-7
Timestamp: 2025-07-14T08:31:33.458Z
Learning: In the drizzle parser refactor (frontend/packages/db-structure/src/parser/drizzle/postgres/mainParser.ts), the import path `../../types.js` correctly references `src/parser/types.ts` from the postgres subdirectory. TypeScript imports use `.js` extensions even when importing `.ts` files, and the build succeeds without issues as confirmed by FunamaYukina.

Applied to files:

  • frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/__tests__/index.test.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts
📚 Learning: 2025-07-14T08:31:33.458Z
Learnt from: FunamaYukina
PR: liam-hq/liam#2556
File: frontend/packages/db-structure/src/parser/drizzle/postgres/mainParser.ts:7-7
Timestamp: 2025-07-14T08:31:33.458Z
Learning: In the drizzle parser refactor (frontend/packages/db-structure/src/parser/drizzle/postgres/mainParser.ts), the import path `../../types.js` correctly references `src/parser/types.js` from the postgres subdirectory, and the build succeeds without issues as confirmed by FunamaYukina.

Applied to files:

  • frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/__tests__/index.test.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts
📚 Learning: 2025-07-07T06:36:07.240Z
Learnt from: FunamaYukina
PR: liam-hq/liam#2299
File: frontend/packages/db-structure/src/parser/drizzle/tableParser.ts:0-0
Timestamp: 2025-07-07T06:36:07.240Z
Learning: In the Drizzle ORM parser (frontend/packages/db-structure/src/parser/drizzle/tableParser.ts), the parseIndexDefinition function properly supports parsing index types from .using() method chains like index('name').on(...).using('btree'), extracting the type from the first argument of the .using() call and handling various PostgreSQL index types such as 'btree', 'gin', 'gist', etc.

Applied to files:

  • frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/__tests__/index.test.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts
📚 Learning: 2025-06-26T05:50:27.762Z
Learnt from: MH4GF
PR: liam-hq/liam#2225
File: frontend/packages/db-structure/src/deparser/postgresql/schemaDeparser.ts:14-14
Timestamp: 2025-06-26T05:50:27.762Z
Learning: In the PostgreSQL schema deparser (frontend/packages/db-structure/src/deparser/postgresql/schemaDeparser.ts), type assertions like `as Table[]` and `as Index[]` are acceptable when used with Object.values() because the schema structure is guaranteed to be correct upstream, making the assertions safe.

Applied to files:

  • frontend/packages/schema/src/parser/drizzle/postgres/tableParser.ts
  • frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Supabase Preview
  • GitHub Check: Supabase Preview
  • GitHub Check: Supabase Preview
🔇 Additional comments (1)
frontend/packages/schema/src/parser/drizzle/postgres/mainParser.ts (1)

175-197: Dispatch order is fine; no change needed.

The isCommentCall fast path remains valid after the generalized handler; it will still parse and set comments. The handler above covers non‑tail $comment() and schema.table chains.

Please run the new tests suggested in the test file to confirm both orderings and schema.table chains parse as expected.

Comment on lines +114 to +139
const isPgTableCall = (callExpr: CallExpression): boolean => {
return isIdentifierWithName(callExpr.callee, 'pgTable')
}
/**
* Extract the base pgTable call from method chaining patterns
* Handles patterns like: pgTable(...).enableRLS(), pgTable(...).comment(...), etc.
*/
export const extractPgTableFromChain = (
callExpr: CallExpression,
): CallExpression | null => {
// If it's already a direct pgTable call, return it
if (isPgTableCall(callExpr)) {
return callExpr
}

// If it's a method call on another expression, check the object
if (callExpr.callee.type === 'MemberExpression') {
const baseCall = callExpr.callee.object
if (baseCall.type === 'CallExpression') {
// Recursively check if the base call is or contains a pgTable call
return extractPgTableFromChain(baseCall)
}
}

return null
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Broaden chain unwrapping utility to include schema.table(...).

extractPgTableFromChain only finds pgTable; callers miss schema.table chains. Provide a general extractor that returns either pgTable(...) or schema.table(...).

Apply (place after isSchemaTableCall to avoid TDZ):

+/**
+ * Extract the base table call (pgTable(...) or schema.table(...)) from a chain.
+ */
+export const extractTableCallFromChain = (
+  callExpr: CallExpression,
+): CallExpression | null => {
+  if (isPgTableCall(callExpr) || isSchemaTableCall(callExpr)) {
+    return callExpr
+  }
+  if (callExpr.callee.type === 'MemberExpression') {
+    const base = callExpr.callee.object
+    if (base.type === 'CallExpression') {
+      return extractTableCallFromChain(base)
+    }
+  }
+  return null
+}

Then use this in mainParser/tableParser (see suggested patches there). This keeps isPgTableCall private while enabling broader support. Based on learnings.

🤖 Prompt for AI Agents
In frontend/packages/schema/src/parser/drizzle/postgres/astUtils.ts around lines
114 to 139, the extractPgTableFromChain only recognizes pgTable(...) calls and
misses schema.table(...) chains; add a new extractor (placed immediately after
isSchemaTableCall to avoid TDZ) that unwraps chained CallExpressions and returns
either a pgTable(...) CallExpression or a schema.table(...) CallExpression
(i.e., check both isPgTableCall and isSchemaTableCall when unwrapping), keep
isPgTableCall private, and update callers (mainParser/tableParser) to use this
broader extractor so schema.table(...) chains are recognized the same way as
pgTable(...).

Copy link
Member Author

Choose a reason for hiding this comment

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

@coderabbitai
Supporting schema.table() requires fundamental schema type restructuring and should be addressed separately.

Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR enhances the Drizzle PostgreSQL parser to correctly handle complex method chaining patterns like .enableRLS().$comment() that were previously causing tables to be ignored during parsing. The fix ensures all tables from Drizzle schemas are properly displayed in ERD visualizations from GitHub folder URLs.

Key Changes:

  • Added recursive method chain traversal to find underlying pgTable calls
  • Refactored parser flow for better modularity and handling of different Drizzle construct types
  • Added comprehensive test coverage for chained method patterns

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
astUtils.ts Added extractPgTableFromChain function to recursively traverse method chains and find base pgTable calls
tableParser.ts Updated comment parsing to use new chain extraction logic for complex method patterns
mainParser.ts Refactored variable declarator parsing into modular functions for different call expression types
index.test.ts Added comprehensive tests for .enableRLS() and .$comment() method chaining patterns
whole-mirrors-throw.md Added changeset entry documenting the bug fix

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines 146 to 149
// Store enum by its actual name (e.g., 'user_role')
enums[enumDef.name] = enumDef
// Also store by variable name (e.g., 'userRoleEnum') for lookup
enums[declarator.id.value] = enumDef
Copy link
Preview

Copilot AI Sep 29, 2025

Choose a reason for hiding this comment

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

Storing enums under both their actual name and variable name could lead to conflicts if an enum's actual name matches another enum's variable name. Consider using a more structured approach to avoid potential overwrites.

Copilot uses AI. Check for mistakes.

Copy link
Member Author

Choose a reason for hiding this comment

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

Previously stored enums under both actual name and variable name, which could
cause overwrites when an enum's actual name matched another enum's variable name.
Now only stores by variable name to ensure unique keys and prevent data loss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Reduced test complexity by using only 1 table per test case instead of 3.
Added separate test for complex chaining pattern (.enableRLS().$comment()).
Tests are now more focused and easier to maintain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@FunamaYukina FunamaYukina changed the title fix(schema): support complex Drizzle method chaining patterns 【⚠️ Do Not Merge】fix(schema): support complex Drizzle method chaining patterns Sep 29, 2025
@FunamaYukina FunamaYukina marked this pull request as ready for review September 29, 2025 12:21
@FunamaYukina FunamaYukina requested a review from a team as a code owner September 29, 2025 12:21
@FunamaYukina FunamaYukina requested review from hoshinotsuyoshi, junkisai, MH4GF and NoritakaIkeda and removed request for a team September 29, 2025 12:21
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.

1 participant