-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published
- by the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source. For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code. There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU AGPL, see
- .
+Specifically:
+1. You may not modify or replace the Teable brand assets
+2. You may not remove the Teable brand assets
+3. You may not use the brand assets in a way that suggests endorsement
diff --git a/Makefile b/Makefile
index 5ebf141080..f6718a2db6 100644
--- a/Makefile
+++ b/Makefile
@@ -23,7 +23,7 @@ else
RESET := ""
endif
-ENV_PATH ?= apps/nextjs-app
+ENV_PATH ?= ./apps/nextjs-app
DOCKER_COMPOSE ?= docker compose
@@ -45,7 +45,7 @@ UNAME_S := $(shell uname -s)
# prisma database url defaults
SQLITE_PRISMA_DATABASE_URL ?= file:../../db/main.db
# set param statement_cache_size=1 to avoid query error `ERROR: cached plan must not change result type` after alter column type (modify field type)
-POSTGES_PRISMA_DATABASE_URL ?= postgresql://teable:teable@127.0.0.1:5432/teable?schema=public\&statement_cache_size=1
+POSTGES_PRISMA_DATABASE_URL ?= postgresql://teable:teable\@127.0.0.1:5432/teable?schema=public\&statement_cache_size=1
# If the first make argument is "start", "stop"...
ifeq (docker.start,$(firstword $(MAKECMDGOALS)))
@@ -56,10 +56,6 @@ else ifeq (docker.restart,$(firstword $(MAKECMDGOALS)))
SERVICE_TARGET = true
else ifeq (docker.up,$(firstword $(MAKECMDGOALS)))
SERVICE_TARGET = true
-else ifeq (docker.build,$(firstword $(MAKECMDGOALS)))
- SERVICE_TARGET = true
-else ifeq (build-nocache,$(firstword $(MAKECMDGOALS)))
- SERVICE_TARGET = true
else ifeq (docker.await,$(firstword $(MAKECMDGOALS)))
SERVICE_TARGET = true
else ifeq (docker.run,$(firstword $(MAKECMDGOALS)))
@@ -141,8 +137,6 @@ ifneq ($(NETWORK_MODE),host)
$(warning ${GREEN}network $(NETWORK_MODE) removed${RESET})
endif
-docker.build:
- $(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) build --parallel --progress=plain $(SERVICE)
docker.run: docker.create.network
$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) run -T --no-deps --rm $(SERVICE) $(SERVICE_ARGS)
@@ -193,11 +187,24 @@ docker.status:
docker.images:
$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) images
+
+build.app:
+ @zx --version || pnpm add -g zx; \
+ zx scripts/build-image.mjs --file=dockers/teable/Dockerfile \
+ --tag=teable:develop
+
+build.db-migrate:
+ @zx --version || pnpm add -g zx; \
+ zx scripts/build-image.mjs --file=dockers/teable/Dockerfile.db-migrate \
+ --tag=teable-db-migrate:develop
+
+
sqlite.integration.test:
@export PRISMA_DATABASE_URL='file:../../db/main.db'; \
+ export CALC_CHUNK_SIZE=400; \
make sqlite.mode; \
pnpm -F "./packages/**" run build; \
- pnpm g:test-e2e
+ pnpm g:test-e2e-cover
postgres.integration.test: docker.create.network
@TEST_PG_CONTAINER_NAME=teable-postgres-$(CI_JOB_ID); \
@@ -205,10 +212,10 @@ postgres.integration.test: docker.create.network
$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) run -p 25432:5432 -d -T --no-deps --rm --name $$TEST_PG_CONTAINER_NAME teable-postgres; \
chmod +x scripts/wait-for; \
scripts/wait-for 127.0.0.1:25432 --timeout=15 -- echo 'pg database started successfully' && \
- export PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:25432/e2e_test_teable?schema=public\&statement_cache_size=1 && \
+ export PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:25432/e2e_test_teable?schema=public\&statement_cache_size=1\&connection_limit=20 && \
make postgres.mode && \
pnpm -F "./packages/**" run build && \
- pnpm g:test-e2e && \
+ pnpm g:test-e2e-cover && \
docker rm -fv $$TEST_PG_CONTAINER_NAME
gen-sqlite-prisma-schema:
@@ -268,18 +275,24 @@ postgres.mode: ## postgres.mode
@cd ./packages/db-main-prisma; \
pnpm prisma-generate --schema ./prisma/postgres/schema.prisma; \
pnpm prisma-migrate deploy --schema ./prisma/postgres/schema.prisma
-
# Override environment variable files based on variables
RUN_DB_MODE ?= sqlite
FILE_ENV_PATHS = $(ENV_PATH)/.env.development* $(ENV_PATH)/.env.test*
switch.prisma.env:
ifeq ($(CI)-$(RUN_DB_MODE),0-sqlite)
@for file in $(FILE_ENV_PATHS); do \
- sed -i '' 's~^PRISMA_DATABASE_URL=.*~PRISMA_DATABASE_URL=$(SQLITE_PRISMA_DATABASE_URL)~' $$file; \
+ echo $$file; \
+ perl -i -pe 's~^PRISMA_DATABASE_URL=.*~PRISMA_DATABASE_URL=$(SQLITE_PRISMA_DATABASE_URL)~' $$file; \
+ if ! grep -q '^CALC_CHUNK_SIZE=' $$file; then \
+ echo "CALC_CHUNK_SIZE=400" >> $$file; \
+ else \
+ perl -i -pe 's~^CALC_CHUNK_SIZE=.*~CALC_CHUNK_SIZE=400~' $$file; \
+ fi; \
done
else ifeq ($(CI)-$(RUN_DB_MODE),0-postges)
@for file in $(FILE_ENV_PATHS); do \
- sed -i '' 's~^PRISMA_DATABASE_URL=.*~PRISMA_DATABASE_URL=$(POSTGES_PRISMA_DATABASE_URL)~' $$file; \
+ echo $$file; \
+ perl -i -pe 's~^PRISMA_DATABASE_URL=.*~PRISMA_DATABASE_URL=$(POSTGES_PRISMA_DATABASE_URL)~' $$file; \
done
endif
diff --git a/README.md b/README.md
index 91cd06fe8b..7abad11ac9 100644
--- a/README.md
+++ b/README.md
@@ -5,26 +5,25 @@
- Postgres-Airtable Fusion
- Teable is a Super fast, Real-time, Professional, Developer friendly, No-code database built on Postgres. It uses a simple, spreadsheet-like interface to create complex enterprise-level database applications. Unlock efficient app development with no-code, free from the hurdles of data security and scalability.
+ Manage Your Data & Connect Your Team
+ Teable uses a simple, spreadsheet-like interface to create powerful database applications. Collaborate with your team in real-time, and scale to millions of rows
+
Try out Teable using our hosted version at teable.ai
+
+
+
+
- Home | Help | Blog | Template | Roadmap | Discord
+ Home | Help | Blog | Template | API | Community | Twitter
-
-
-
-
-
-
-
-
+
+
@@ -32,11 +31,10 @@
-
-
+
+
-
@@ -46,104 +44,68 @@
## Quick Guide
-1. Looking for a quick experience? Select a scenario from the [template center](https://template.teable.io) and click "Use this template".
-2. Seeking high performance? Try the [1 million rows demo](https://app.teable.io/share/shrVgdLiOvNQABtW0yX/view) to feel the speed of Teable.
-3. Want to learn to use it quickly? Click on this [tutorial](https://help.teable.io/quick-start/build-a-simple-base)
-4. Interested in deploying it yourself? Click [Deploy on Railway](https://railway.app/template/wada5e?referralCode=rE4BjB)
+1. Looking for a quick experience? Select a scenario from the [template center](https://app.teable.ai/public/template) and click "Use this template".
+2. Seeking high performance? Try the [1 million rows demo](https://app.teable.ai/share/shrVgdLiOvNQABtW0yX/view) to feel the speed of Teable.
+3. Interested in deploying it yourself? Click [Deploy on Railway](https://railway.app/template/wada5e?referralCode=rE4BjB)
## ✨Features
-#### 📊 Spreadsheet-like interface
-
-All you want is here
-
-- Cell Editing: Directly click and edit content within cells.
-- Formula Support: Input mathematical and logical formulas to auto-calculate values.
-- Data Sorting and Filtering: Sort data based on a column or multiple columns; use filters to view specific rows of data.
-- Aggregation Function: Automatically summarize statistics for each column, providing instant calculations like sum, average, count, max, and min for streamlined data analysis.
-- Data Formatting: formatting numbers, dates, etc.
-- Grouping: Organize rows into collapsible groups based on column values for easier data analysis and navigation.
-- Freeze Columns: Freeze the left column of the table so they remain visible while scrolling.
-- Import/Export Capabilities: Import and export data from other formats, e.g., .csv, .xlsx.
-- Row Styling & Conditional Formatting: Change row styles automatically based on specific conditions. (coming soon)
-- Charts & Visualization Tools: Create charts from table data such as bar charts, pie charts, line graphs, etc. (coming soon)
-- Data Validation: Limit or validate data that are entered into cells. (coming soon)
-- Undo/Redo: Undo or redo recent changes. (coming soon)
-- Comments & Annotations: Attach comments to rows, providing explanations or feedback for other users. (coming soon)
-- Find & Replace: Search content within the table and replace it with new content. (coming soon)
-
-#### 🗂️ Multiple Views
+### 🍺 Feature Packed
+
+Everything you need, right out of the box:
+
+- [x] Aggregation
+- [x] Attachments Preview
+- [x] Batch Editing
+- [x] Charts
+- [x] Comments
+- [x] Custom Columns
+- [x] Field Conversion
+- [x] Filtering
+- [x] Formatting
+- [x] Formula Support
+- [x] Grouping
+- [x] History
+- [x] Import/Export
+- [x] Millions of Rows
+- [x] Plugins
+- [x] Real-time
+- [x] Search
+- [x] Sorting
+- [x] SQL Query
+- [x] Undo/Redo
+- [x] Validation
+
+### 🏞️ Multiple Views
Visualize and interact with data in various ways best suited for their specific tasks.
-- Grid View: The default view of the table, which displays data in a spreadsheet-like format.
-- Form View: Input data in a form format, which is useful for collecting data.
-- Kanban View: Displays data in a Kanban board, which is a visual representation of data in columns and cards. (coming soon)
-- Calendar View: Displays data in a calendar format, which is useful for tracking dates and events. (coming soon)
-- Gallery View: Displays data in a gallery format, which is useful for displaying images and other media. (coming soon)
-- Gantt View: Displays data in a Gantt chart, which is useful for tracking project schedules. (coming soon)
-- Timeline View: Displays data in a timeline format, which is useful for tracking events over time. (coming soon)
-
-#### 🚀 Super Fast
-
-Amazing response speed and data capacity
-
-- Millions of data are easily processed, and there is no pressure to filter and sort
-- Automatic database indexing for maximum speed
-- Supports batch data operations at one time
-
-#### 👨💻 Full-featured SQL Support
-
-Seamless integration with the software you are familiar with
-
-- BI tools like Metabase PowerBi...
-- No-code tools like Appsmith...
-- Direct retrieve data with native SQL
-
-#### 🔒 Privacy-First
-
-You own your data, in spite of the cloud
-
-- Bring your own database (coming soon)
-
-#### ⚡️ Real-time collaboration
-
-Designed for teams
-
-- No need to refresh the page, data is updated in real-time
-- Seamlessly integrate collaboration member invitation and management
-- Perfect permission management mechanism, from table to column level
-
-#### 🧩 Extensions (coming soon)
-
-Expand infinite possibilities
-
-- Backend-less programming capability based on React
-- Customize your own application with extremely low cost
-- Extremely easy-to-use script extensions mode
-
-#### 🤖 Automation (coming soon)
-
-Empower data-driven workflows effortlessly and seamlessly
-
-- Design your workflow with AI or Visual programming
-- Super easy to retrieve data from the table
-
-#### 🧠 Copilot (coming soon)
-
-Native Integrated AI ability
-
-- Chat 2 App. "Create a project management app for me"
-- Chat 2 Chart. "Analyze the data in the order table using a bar chart"
-- Chat 2 View. "I want to see the schedule for the past week and only display participants"
-- Chat 2 Action. "After the order is paid and completed, an email notification will be sent to the customer"
-- More actions...
-
-#### 🗄️ Support for multiple databases (coming soon)
-
-Choose the SQL database you like
-
-- Sqlite, PostgreSQL, MySQL, MariaDB, TiDB...
+- [x] Grid View
+- [x] Form View
+- [x] Kanban View
+- [x] Gallery View
+- [x] Calendar View
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+More features have been added. See our Changelog .
---
@@ -153,68 +115,76 @@ Choose the SQL database you like
```
.
-├── apps
-│ ├── electron (desktop, include a electron app )
-│ ├── nextjs-app (front-end, include a nextjs app)
-│ └── nestjs-backend (backend, running on server or inside electron app)
-└── packages
- ├── common-i18n (locales)
- ├── core (share code and interface)
- ├── sdk (sdk for extensions)
- ├── db-main-prisma (schema, migrations, prisma client)
- ├── eslint-config-bases (to shared eslint configs)
- └── ui-lib (ui component)
+├── apps (AGPL 3.0)
+│ ├── nextjs-app (front-end)
+│ └── nestjs-backend (backend)
+├── packages (MIT)
+│ ├── common-i18n (locales)
+│ ├── core (share code and interface)
+│ ├── sdk (sdk for extensions)
+│ ├── db-main-prisma (schema, migrations, prisma client)
+│ ├── eslint-config-bases (to shared eslint configs)
+│ └── ui-lib (ui component)
+└── plugins (AGPL 3.0) (custom plugins)
+
```
## Deploy
-### Deploy with docker
+### Deploy With Docker
```sh
cd dockers/examples/standalone/
docker-compose up -d
```
-for more details, see [dockers/examples](dockers/examples)
+for more details, see [install teable](https://help.teable.ai/en/deploy/docker)
+
+### One Click Deployment
-### Deploy with Railway
+These platforms are easy to deploy with one click and come with free credits.
[](https://railway.app/template/wada5e?referralCode=rE4BjB)
+[](https://template.sealos.io/deploy?templateName=teable)
+
+[](https://zeabur.com/templates/QF8695)
+
+[](https://repocloud.io/details/?app_id=273)
+
+[](https://elest.io/open-source/teable)
+
+[](https://computenest.console.aliyun.com/service/instance/create/default?ServiceName=Teable%20%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+
## Development
#### 1. Initialize
```sh
-# Use `.nvmrc` file to specify node version(Requires pre `nvm` tools)
-nvm install && nvm use
-
# Enabling the Help Management Package Manager
corepack enable
# Install project dependencies
pnpm install
-
-# Build packages
-pnpm g:build
```
#### 2. Select Database
-we currently support `sqlite` and `postgres`, you can switch between them by running the following command
+we currently support `sqlite` (dev only) and `postgres`, you can switch between them by running the following command
```sh
make switch-db-mode
```
-#### 3. Custom environment variables(optional)
+#### 3. Custom Environment Variables(Optional)
```sh
cd apps/nextjs-app
-copy .env.development .env.development.local
+cp .env.development .env.development.local
```
-#### 4. Run dev server
+#### 4. Run Dev Server
you just need to start backend, it will start next server for frontend automatically, file change will be auto reload
@@ -223,6 +193,18 @@ cd apps/nestjs-backend
pnpm dev
```
+By default, the plugin development server is not started. To preview and develop plugins, run:
+```sh
+# build packages
+pnpm build:packages
+
+# start plugin development server
+cd plugins
+pnpm dev
+```
+This will start the plugin development server on port 3002.
+
+
## Why Teable?
No-code tools have significantly speed up how we get things done, allowing non-tech users to build amazing apps and changing the way many work and live. People like using spreadsheet-like UI to handle their data because it's easy, flexible, and great for team collaboration. They also prefer designing their app screens without being stuck with clunky templates.
@@ -236,7 +218,7 @@ Giving non-techy people the ability to create their software sounds exciting. Bu
- Maintaining systems with complex setups can be hard for developers, especially if these aren't built using common software standards.
- Systems that don't use these standards might need revamping or replacing, costing more in the long run. It might even mean ditching the no-code route and going back to traditional coding.
-#### What we think the future of no-code products look like
+#### What We Think the Future Of No-code Products Look Like
- An interface that anyone can use to build applications easily.
- Easy access to data, letting users grab, move, and reuse their information as they wish.
@@ -248,11 +230,8 @@ Giving non-techy people the ability to create their software sounds exciting. Bu
In essence, Teable isn't just another no-code solution, it's a comprehensive answer to the evolving demands of modern software development, ensuring that everyone, regardless of their technical proficiency, has a platform tailored to their needs.
-## Sponsors :heart:
-
-If you are enjoying some this project in your company, I'd really appreciate a [sponsorship](https://github.com/sponsors/teableio), a [coffee](https://ko-fi.com/teable) or a dropped star.
-That gives me some more time to improve it to the next level.
-
# License
-AGPL-3.0
+Teable Community Edition (CE) is free for self-hosting under the AGPL license. See [./LICENSE](./LICENSE) for details.
+
+Teable Enterprise Edition (EE) includes advanced features such as AI, authority matrix, automation and advanced admin. For detailed information and pricing, please visit [pricing](https://app.teable.ai/public/pricing?host=self-hosted&billing=year).
diff --git a/agents.md b/agents.md
new file mode 100644
index 0000000000..f75c711361
--- /dev/null
+++ b/agents.md
@@ -0,0 +1,73 @@
+# Teable v2 agent guide
+
+DDD/domain-model guidance has moved to the skill `teable-ddd-domain-model` in `.codex/skills/teable-ddd-domain-model`. Use that skill for any v2/core domain, specification, or aggregate changes.
+
+## Git hygiene
+
+- Ignore git changes that you did not make by default; never revert unknown/unrelated modifications unless explicitly instructed.
+
+## v2 API contracts (HTTP)
+
+For HTTP-ish integrations, keep framework-independent contracts/mappers in `packages/v2/contract-http`:
+
+- Define API paths (e.g. `/tables`) as constants.
+- Use action-style paths with camelCase action names (e.g. `/tables/create`, `/tables/get`, `/tables/rename`); avoid RESTful nested resources like `/bases/{baseId}/tables/{tableId}`.
+- Re-export command input schemas (zod) for route-level validation if needed.
+- Keep DTO types + domain-to-DTO mappers here.
+- Router packages (e.g. `@teable/v2-contract-http-express`, `@teable/v2-contract-http-fastify`) should be thin adapters that only:
+ - parse JSON/body
+ - create a container
+ - resolve handlers
+ - call the endpoint executor/mappers from `@teable/v2-contract-http`
+- OpenAPI is generated from the ts-rest contract via `@teable/v2-contract-http-openapi`.
+
+## UI components (frontend)
+
+- In app UIs, use local shadcn wrappers (or `@teable/ui-lib`) instead of importing Radix primitives directly.
+- If a shadcn wrapper is missing for an app UI, add it under that app's local `src/components/ui` before using the primitive.
+
+## Dependency injection (DI)
+
+- Do not import `tsyringe` / `reflect-metadata` directly anywhere; use `@teable/v2-di`.
+- Do not use DI inside `v2/core/src/domain/**`; DI is only for application wiring (e.g. `v2/core/src/commands/**`).
+- Prefer constructor injection with explicit tokens for ports (interfaces).
+- Provide environment-level composition roots as separate packages (e.g. `@teable/v2-container-node`, `@teable/v2-container-browser`) that register all port implementations.
+
+## Build tooling (v2)
+
+- v2 packages build with `tsdown` (not `tsc` emit). `tsc` is used only for `typecheck` (`--noEmit`).
+- Each v2 package has a local `tsdown.config.ts` that extends the shared base config from `@teable/v2-tsdown-config`.
+- Outputs are written to `dist/` (ESM `.js` + `.d.ts`), and workspace deps (`@teable/v2-*`) are kept external (no bundling across packages).
+
+## Source visibility (v2 packages)
+
+**All v2 packages must support source visibility** to allow consumers to reference TypeScript sources without building `dist/` outputs. This is required for development workflows, testing, and tools like Vitest/Vite that can consume TypeScript directly.
+
+**Required configuration:**
+
+- In `package.json`:
+ - Set `types` field to `"src/index.ts"` (not `"dist/index.d.ts"`)
+ - Set `exports["."].types` to `"./src/index.ts"` (not `"./dist/index.d.ts"`)
+ - Set `exports["."].import` to `"./src/index.ts"` (not `"./dist/index.js"`) to allow Vite/Vitest to use source files directly
+ - Keep `exports["."].require` pointing to `"./dist/index.cjs"` for CommonJS compatibility
+ - Include `"src"` in the `files` array (in addition to `"dist"`)
+- In `tsconfig.json`:
+ - Map workspace dependencies to their `src` paths in `compilerOptions.paths` (e.g. `"@teable/v2-core": ["../core/src"]`)
+ - Include those source paths in the `include` array
+
+**Example `package.json` configuration:**
+```json
+{
+ "types": "src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "files": ["dist", "src"]
+}
+```
+
+**Note:** Since v2 packages are workspace-only (`"private": true`) and not published to npm, pointing `import` to source files is safe. Vite/Vitest can process TypeScript files directly, enabling faster development cycles without requiring `dist/` to be built first.
diff --git a/apps/electron/.gitignore b/apps/electron/.gitignore
deleted file mode 100644
index 895e025e01..0000000000
--- a/apps/electron/.gitignore
+++ /dev/null
@@ -1,96 +0,0 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-lerna-debug.log*
-
-# Diagnostic reports (https://nodejs.org/api/report.html)
-report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-
-# Runtime data
-pids
-*.pid
-*.seed
-*.pid.lock
-.DS_Store
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-*.lcov
-
-# nyc test coverage
-.nyc_output
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (https://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules/
-jspm_packages/
-
-# TypeScript v1 declaration files
-typings/
-
-# TypeScript cache
-*.tsbuildinfo
-
-# Optional npm cache directory
-.npm
-
-# Optional eslint cache
-.eslintcache
-
-# Optional REPL history
-.node_repl_history
-
-# Output of 'npm pack'
-*.tgz
-
-# Yarn Integrity file
-.yarn-integrity
-
-# dotenv environment variables file
-.env
-.env.test
-
-# parcel-bundler cache (https://parceljs.org/)
-.cache
-
-# next.js build output
-.next
-
-# nuxt.js build output
-.nuxt
-
-# vuepress build output
-.vuepress/dist
-
-# Serverless directories
-.serverless/
-
-# FuseBox cache
-.fusebox/
-
-# DynamoDB Local files
-.dynamodb/
-
-# Webpack
-.webpack/
-
-# Vite
-.vite/
-
-# Electron-Forge
-out/
-
-server/
-
-.yarn/
\ No newline at end of file
diff --git a/apps/electron/README.md b/apps/electron/README.md
deleted file mode 100644
index 3421e516f9..0000000000
--- a/apps/electron/README.md
+++ /dev/null
@@ -1,73 +0,0 @@
-# @teable/electron
-
-This is a repository in a monorepo project used to package applications into Electron desktop apps.
-
-## Getting Started
-
-### Install Dependencies
-
-Run the following command in the root directory to install the dependencies:
-
-```
-yarn install
-```
-
-### Development Mode
-
-Run the following command to start the development mode, which loads the local web application in Electron:
-
-```
-yarn start
-```
-
-> tips: Ensure that nest is start.
-
-### Start prepare
-
-Build all nextjs and nestjs dependent packages:
-
-```
-yarn g:build
-```
-
-Run prepare scripts
-
-```
-yarn prepare:server
-```
-
-### Building the App
-
-Run the following command to package the application into an Electron desktop app:
-
-- Build mac:
-
-```
-yarn make:mac
-```
-
-- Build windows:
-
-```
-yarn make:win
-```
-
-The packaged app will be generated in the `out` directory.
-
-Debug build:
-
-```
-yarn package:debug
-```
-
-## Notes
-
-- Make sure you have Node.js and npm installed on your local machine.
-- Packaging the Electron app may take some time, please be patient.
-- If you encounter any issues during the packaging process, check the console output or log files for error information.
-
-## TODO
-
-- [ ] Organize environment variable configuration.
-- [ ] The database file can be initialized to any location.
-- [ ] Optimize packing volume.
diff --git a/apps/electron/forge.config.js b/apps/electron/forge.config.js
deleted file mode 100644
index fefcbedf36..0000000000
--- a/apps/electron/forge.config.js
+++ /dev/null
@@ -1,93 +0,0 @@
-const path = require('path');
-
-module.exports = {
- packagerConfig: {
- appId: 'YourAppID',
- name: 'TeableApp',
- osxSign: {},
- icon: 'static/icons/icon',
- ignore: (file) => {
- const isTsOrMap = (p) => /[^/\\]+\.js\.map$/.test(p) || /[^/\\]+\.ts$/.test(p);
- if (!file) return false;
-
- if (file.startsWith('/.vite')) {
- return false;
- }
-
- if (file === '/package.json') {
- return false;
- }
-
- if (file.startsWith('/static')) {
- return false;
- }
-
- if (
- file.startsWith('/server') &&
- !isTsOrMap(file) &&
- !file.startsWith('/server/.yarn') &&
- !file.startsWith('/server/apps/nextjs-app/.next/cache')
- ) {
- return false;
- }
-
- if (file.startsWith('/node_modules') && !isTsOrMap(file)) {
- return false;
- }
-
- return true;
- },
- },
- rebuildConfig: {},
- makers: [
- {
- name: '@electron-forge/maker-zip',
- config: {},
- },
- {
- name: '@electron-forge/maker-squirrel',
- config: {},
- },
- // {
- // name: '@electron-forge/maker-deb',
- // config: {},
- // },
- // {
- // name: '@electron-forge/maker-rpm',
- // config: {},
- // },
- {
- name: '@electron-forge/maker-dmg',
- config: {
- background: path.join(__dirname, 'static', 'background.png'),
- icon: path.join(__dirname, 'static', 'icons', 'icon.icns'),
- },
- },
- ],
- plugins: [
- {
- name: '@electron-forge/plugin-vite',
- config: {
- // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
- // If you are familiar with Vite configuration, it will look really familiar.
- build: [
- {
- // `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
- entry: 'src/main.js',
- config: 'vite.main.config.mjs',
- },
- {
- entry: 'src/preload.js',
- config: 'vite.preload.config.mjs',
- },
- ],
- renderer: [
- {
- name: 'main_window',
- config: 'vite.renderer.config.mjs',
- },
- ],
- },
- },
- ],
-};
diff --git a/apps/electron/index.html b/apps/electron/index.html
deleted file mode 100644
index 1556a2a95c..0000000000
--- a/apps/electron/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
- Hello World!
-
-
- 💖 Hello World!
- Welcome to your Electron application.
-
-
-
diff --git a/apps/electron/package.json b/apps/electron/package.json
deleted file mode 100644
index 5f9c937e57..0000000000
--- a/apps/electron/package.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "name": "@table-group/electron",
- "productName": "electron-vite",
- "version": "1.0.0",
- "description": "My Electron application description",
- "main": ".vite/build/main.js",
- "scripts": {
- "prepare:server": "rm -rf server && node ./scripts/prepare-server.js",
- "start": "electron-forge start",
- "package": "electron-forge package",
- "package:debug": "electron-forge package && out/TeableApp-darwin-x64/TeableApp.app/Contents/MacOS/TeableApp --enable-logging",
- "make:mac": "electron-forge make --platform=mas --arch=x64",
- "make:win": "electron-forge make --platform=win32 --arch=x64",
- "publish": "electron-forge publish",
- "lint": "echo \"No linting configured\""
- },
- "keywords": [],
- "author": {
- "name": "boris",
- "email": "boris2code@outlook.com"
- },
- "license": "MIT",
- "devDependencies": {
- "@electron-forge/cli": "6.2.1",
- "@electron-forge/maker-deb": "6.2.1",
- "@electron-forge/maker-dmg": "6.2.1",
- "@electron-forge/maker-rpm": "6.2.1",
- "@electron-forge/maker-squirrel": "6.2.1",
- "@electron-forge/maker-zip": "6.2.1",
- "@electron-forge/plugin-auto-unpack-natives": "6.2.1",
- "@electron-forge/plugin-vite": "6.2.1",
- "electron": "25.3.0",
- "is-port-reachable": "3.1.0"
- },
- "dependencies": {
- "electron-squirrel-startup": "1.0.0"
- }
-}
diff --git a/apps/electron/scripts/prepare-server.js b/apps/electron/scripts/prepare-server.js
deleted file mode 100644
index 65ee3481cb..0000000000
--- a/apps/electron/scripts/prepare-server.js
+++ /dev/null
@@ -1,83 +0,0 @@
-const path = require('path');
-const { execSync } = require('child_process');
-const { copySync, writeFileSync } = require('fs-extra');
-
-const root = path.join(__dirname, '../../../');
-
-// enter project directory
-const serverOutput = 'apps/electron/server';
-
-const packages = [
- {
- path: '',
- files: ['package.json', '.yarnrc.yml', '.yarn/releases', '.yarn/plugins', 'static'],
- },
- {
- path: 'apps/nestjs-backend',
- files: ['package.json', 'dist'],
- },
- {
- path: 'apps/nextjs-app',
- files: ['package.json', '.next', '.env', 'public'],
- },
- {
- path: 'packages/core',
- files: ['package.json', 'dist'],
- },
- {
- path: 'packages/db-main-prisma',
- files: ['package.json', 'dist', 'prisma', '.env'],
- },
- {
- path: 'packages/icons',
- files: ['package.json', 'dist'],
- },
- {
- path: 'packages/openapi',
- files: ['package.json', 'dist'],
- },
- {
- path: 'packages/sdk',
- files: ['package.json', 'dist'],
- },
- {
- path: 'packages/ui-lib',
- files: ['package.json', 'dist'],
- },
- {
- path: 'packages/common-i18n',
- files: ['package.json', 'src'],
- },
-];
-
-function copyPackages() {
- packages.forEach((pkg) => {
- console.log('begin copy...', pkg.path);
- pkg.files.forEach((file) => {
- const src = path.join(root, `${pkg.path}/${file}`);
- const dest = path.join(root, `${serverOutput}/${pkg.path}/${file}`);
- copySync(src, dest);
- });
- console.log('completed ✅');
- });
- console.log('🎉 copy packages success!!!');
-}
-
-function fixPostinstall() {
- packages.forEach((pkg) => {
- const packageJsonPath = path.join(root, serverOutput, pkg.path, 'package.json');
- const packageJson = require(packageJsonPath);
- if (pkg.path.includes('db-main-prisma') || !packageJson?.scripts?.postinstall) {
- return;
- }
- delete packageJson.scripts.postinstall;
-
- writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
- });
-}
-
-copyPackages();
-writeFileSync('server/yarn.lock', '');
-fixPostinstall();
-
-execSync('yarn workspaces focus --production --all', { cwd: 'server/', stdio: 'inherit' });
diff --git a/apps/electron/src/env.js b/apps/electron/src/env.js
deleted file mode 100644
index 75e985e669..0000000000
--- a/apps/electron/src/env.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { getAvailablePort } from './utils';
-const path = require('path');
-
-export const initEnv = async () => {
- const defaultPort = 3000;
-
- process.env.ELECTRON_DEV = Boolean(MAIN_WINDOW_VITE_DEV_SERVER_URL);
-
- if (process.env.ELECTRON_DEV === 'true') {
- process.env.PORT = defaultPort;
- return;
- }
- const port = await getAvailablePort(defaultPort);
- process.env.STATIC_PATH = path.join(__dirname, '../..', 'static');
- process.env.NODE_ENV = 'production';
- process.env.SOCKET_PORT = port;
- process.env.PORT = port;
- process.env.NEXTJS_DIR = path.join(process.resourcesPath, '/app/server/apps/nextjs-app');
- process.env.I18N_LOCALES_PATH = path.join(
- process.resourcesPath,
- '/app/server/packages/common-i18n/src/locales'
- );
-};
diff --git a/apps/electron/src/index.css b/apps/electron/src/index.css
deleted file mode 100644
index 8ed1659832..0000000000
--- a/apps/electron/src/index.css
+++ /dev/null
@@ -1,6 +0,0 @@
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
- margin: auto;
- max-width: 38rem;
- padding: 2rem;
-}
diff --git a/apps/electron/src/main.js b/apps/electron/src/main.js
deleted file mode 100644
index 840bbc93f3..0000000000
--- a/apps/electron/src/main.js
+++ /dev/null
@@ -1,60 +0,0 @@
-const { app, BrowserWindow } = require('electron');
-const path = require('path');
-import { startServer } from './server';
-import { initEnv } from './env';
-
-// Handle creating/removing shortcuts on Windows when installing/uninstalling.
-if (require('electron-squirrel-startup')) {
- app.quit();
-}
-
-const createWindow = async () => {
- await initEnv();
- // Create the browser window.
- const mainWindow = new BrowserWindow({
- width: 800,
- height: 600,
- title: 'TeableApp',
- icon: path.join(process.env.STATIC_PATH, 'icons', 'icon.png'),
- webPreferences: {
- preload: path.join(__dirname, 'preload.js'),
- nodeIntegration: true,
- nodeIntegrationInWorker: true,
- scrollBounce: true,
- },
- });
-
- // and load the index.html of the app.
- mainWindow.loadFile(path.join(process.env.STATIC_PATH, 'loading.html'));
- // Open the DevTools.
- process.env.ELECTRON_DEV === 'true' && mainWindow.webContents.openDevTools();
-
- startServer(mainWindow);
-};
-
-// This method will be called when Electron has finished
-// initialization and is ready to create browser windows.
-// Some APIs can only be used after this event occurs.
-app.on('ready', async () => {
- await createWindow();
-});
-
-// Quit when all windows are closed, except on macOS. There, it's common
-// for applications and their menu bar to stay active until the user quits
-// explicitly with Cmd + Q.
-app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') {
- app.quit();
- }
-});
-
-app.on('activate', async () => {
- // On OS X it's common to re-create a window in the app when the
- // dock icon is clicked and there are no other windows open.
- if (BrowserWindow.getAllWindows().length === 0) {
- await createWindow();
- }
-});
-
-// In this file you can include the rest of your app's specific main process
-// code. You can also put them in separate files and import them here.
diff --git a/apps/electron/src/preload.js b/apps/electron/src/preload.js
deleted file mode 100644
index 5e9d369cc9..0000000000
--- a/apps/electron/src/preload.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// See the Electron documentation for details on how to use preload scripts:
-// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
diff --git a/apps/electron/src/renderer.js b/apps/electron/src/renderer.js
deleted file mode 100644
index 22f238be8a..0000000000
--- a/apps/electron/src/renderer.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * This file will automatically be loaded by vite and run in the "renderer" context.
- * To learn more about the differences between the "main" and the "renderer" context in
- * Electron, visit:
- *
- * https://electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes
- *
- * By default, Node.js integration in this file is disabled. When enabling Node.js integration
- * in a renderer process, please be aware of potential security implications. You can read
- * more about security risks here:
- *
- * https://electronjs.org/docs/tutorial/security
- *
- * To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration`
- * flag:
- *
- * ```
- * // Create the browser window.
- * mainWindow = new BrowserWindow({
- * width: 800,
- * height: 600,
- * webPreferences: {
- * nodeIntegration: true
- * }
- * });
- * ```
- */
-
-import './index.css';
-
-console.log('👋 This message is being logged by "renderer.js", included via Vite');
diff --git a/apps/electron/src/server.js b/apps/electron/src/server.js
deleted file mode 100644
index 2b5cff3112..0000000000
--- a/apps/electron/src/server.js
+++ /dev/null
@@ -1,12 +0,0 @@
-const path = require('path');
-
-export const startServer = async (mainWindow) => {
- if (process.env.ELECTRON_DEV === 'true') {
- return;
- }
-
- let p = path.join(process.resourcesPath, '/app/server/apps/nestjs-backend/dist/bootstrap.js');
- const backend = require(p);
- await backend.bootstrap();
- mainWindow.loadURL(`http://localhost:${process.env.PORT}/space`);
-};
diff --git a/apps/electron/src/utils.js b/apps/electron/src/utils.js
deleted file mode 100644
index 575772d026..0000000000
--- a/apps/electron/src/utils.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import isPortReachable from 'is-port-reachable';
-
-export async function getAvailablePort(dPort) {
- let port = Number(dPort);
- const host = 'localhost';
- while (await isPortReachable(port, { host })) {
- console.log(`> Fail on http://${host}:${port} Trying on ${port + 1}`);
- port++;
- }
- return port;
-}
diff --git a/apps/electron/static/background.png b/apps/electron/static/background.png
deleted file mode 100644
index 64173937e1..0000000000
Binary files a/apps/electron/static/background.png and /dev/null differ
diff --git a/apps/electron/static/icons/icon.icns b/apps/electron/static/icons/icon.icns
deleted file mode 100644
index 70db60d7d3..0000000000
Binary files a/apps/electron/static/icons/icon.icns and /dev/null differ
diff --git a/apps/electron/static/icons/icon.ico b/apps/electron/static/icons/icon.ico
deleted file mode 100644
index b5540dbef5..0000000000
Binary files a/apps/electron/static/icons/icon.ico and /dev/null differ
diff --git a/apps/electron/static/icons/icon.png b/apps/electron/static/icons/icon.png
deleted file mode 100644
index b5540dbef5..0000000000
Binary files a/apps/electron/static/icons/icon.png and /dev/null differ
diff --git a/apps/electron/static/loading.html b/apps/electron/static/loading.html
deleted file mode 100644
index 56560f1897..0000000000
--- a/apps/electron/static/loading.html
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
- Loading
-
-
-
-
-
-
diff --git a/apps/electron/vite.main.config.mjs b/apps/electron/vite.main.config.mjs
deleted file mode 100644
index c93ad03824..0000000000
--- a/apps/electron/vite.main.config.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-import { defineConfig } from 'vite';
-
-// https://vitejs.dev/config
-export default defineConfig({
- resolve: {
- // Some libs that can run in both Web and Node.js, such as `axios`, we need to tell Vite to build them in Node.js.
- browserField: false,
- mainFields: ['module', 'jsnext:main', 'jsnext'],
- },
-});
diff --git a/apps/electron/vite.preload.config.mjs b/apps/electron/vite.preload.config.mjs
deleted file mode 100644
index 690be5b1a9..0000000000
--- a/apps/electron/vite.preload.config.mjs
+++ /dev/null
@@ -1,4 +0,0 @@
-import { defineConfig } from 'vite';
-
-// https://vitejs.dev/config
-export default defineConfig({});
diff --git a/apps/electron/vite.renderer.config.mjs b/apps/electron/vite.renderer.config.mjs
deleted file mode 100644
index 690be5b1a9..0000000000
--- a/apps/electron/vite.renderer.config.mjs
+++ /dev/null
@@ -1,4 +0,0 @@
-import { defineConfig } from 'vite';
-
-// https://vitejs.dev/config
-export default defineConfig({});
diff --git a/apps/electron/yarn.lock b/apps/electron/yarn.lock
deleted file mode 100644
index bb601ee87c..0000000000
--- a/apps/electron/yarn.lock
+++ /dev/null
@@ -1,5087 +0,0 @@
-# This file is generated by running "yarn install" inside your project.
-# Manual changes might be lost - proceed with caution!
-
-__metadata:
- version: 6
- cacheKey: 8
-
-"@electron-forge/cli@npm:^6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/cli@npm:6.2.1"
- dependencies:
- "@electron-forge/core": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- "@electron/get": ^2.0.0
- chalk: ^4.0.0
- commander: ^4.1.1
- debug: ^4.3.1
- fs-extra: ^10.0.0
- listr2: ^5.0.3
- semver: ^7.2.1
- bin:
- electron-forge: dist/electron-forge.js
- electron-forge-vscode-nix: script/vscode.sh
- electron-forge-vscode-win: script/vscode.cmd
- checksum: d17953906ce330965625bd46ab5a0f09ca3c9243f61d55709f358df2f2cecf508eed32938c15321cea938644245b1e89a266c1856f1f530de0320e8a746c7c13
- languageName: node
- linkType: hard
-
-"@electron-forge/core-utils@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/core-utils@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- "@electron/rebuild": ^3.2.10
- "@malept/cross-spawn-promise": ^2.0.0
- chalk: ^4.0.0
- debug: ^4.3.1
- find-up: ^5.0.0
- fs-extra: ^10.0.0
- log-symbols: ^4.0.0
- semver: ^7.2.1
- yarn-or-npm: ^3.0.1
- checksum: 67ce49e67f4c094311f5a4def1ef029be8332e62b07517d32ce37c5804975b421a0ac97455673452a202fdd821b3c6330b2341f18f63b7ade42648778fec43f3
- languageName: node
- linkType: hard
-
-"@electron-forge/core@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/core@npm:6.2.1"
- dependencies:
- "@electron-forge/core-utils": 6.2.1
- "@electron-forge/maker-base": 6.2.1
- "@electron-forge/plugin-base": 6.2.1
- "@electron-forge/publisher-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- "@electron-forge/template-base": 6.2.1
- "@electron-forge/template-vite": 6.2.1
- "@electron-forge/template-webpack": 6.2.1
- "@electron-forge/template-webpack-typescript": 6.2.1
- "@electron/get": ^2.0.0
- "@electron/rebuild": ^3.2.10
- "@malept/cross-spawn-promise": ^2.0.0
- chalk: ^4.0.0
- debug: ^4.3.1
- electron-packager: ^17.1.1
- fast-glob: ^3.2.7
- filenamify: ^4.1.0
- find-up: ^5.0.0
- fs-extra: ^10.0.0
- got: ^11.8.5
- interpret: ^3.1.1
- listr2: ^5.0.3
- lodash: ^4.17.20
- log-symbols: ^4.0.0
- node-fetch: ^2.6.7
- progress: ^2.0.3
- rechoir: ^0.8.0
- resolve-package: ^1.0.1
- semver: ^7.2.1
- source-map-support: ^0.5.13
- sudo-prompt: ^9.1.1
- username: ^5.1.0
- yarn-or-npm: ^3.0.1
- checksum: c6d9bc103d0a6ebd8aebed36c93e576c375c411f9e31ca66cc5de65f8bad4b204d5fb45511a66724c94528ae999186fe6bd814cc6381d154b8a9feb449cd3aa5
- languageName: node
- linkType: hard
-
-"@electron-forge/maker-base@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/maker-base@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- fs-extra: ^10.0.0
- which: ^2.0.2
- checksum: 0b22f5dce43f3b15088ba2fff660918f4538c7e0d15b803a5df93a4022617d410eaf72e9d0838da5851480d4ca852b9dd4d030d03c4f61e3586c8e58b93e7a11
- languageName: node
- linkType: hard
-
-"@electron-forge/maker-deb@npm:^6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/maker-deb@npm:6.2.1"
- dependencies:
- "@electron-forge/maker-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- electron-installer-debian: ^3.0.0
- dependenciesMeta:
- electron-installer-debian:
- optional: true
- checksum: 1d2e1f4411e16e971fc5828328036dba1258b6ef7377b87af694f359e7c910ca20723285f11250b1ef61593bb30e28457551eff1b8ad26c8fb6ec0c5ac858359
- languageName: node
- linkType: hard
-
-"@electron-forge/maker-dmg@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/maker-dmg@npm:6.2.1"
- dependencies:
- "@electron-forge/maker-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- electron-installer-dmg: ^4.0.0
- fs-extra: ^10.0.0
- dependenciesMeta:
- electron-installer-dmg:
- optional: true
- checksum: 7e00dfa17ac5045f7163bef8836869abc0940e6588641756a5ffea5d93b5c477d93ccb42ef223c2b4a018406466e4a8230663591fff48f1688acf54314e5e366
- languageName: node
- linkType: hard
-
-"@electron-forge/maker-rpm@npm:^6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/maker-rpm@npm:6.2.1"
- dependencies:
- "@electron-forge/maker-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- electron-installer-redhat: ^3.2.0
- dependenciesMeta:
- electron-installer-redhat:
- optional: true
- checksum: 47d5b1f3b94075dc9e7d5e4b73a9bad5411c97c287688b1416fe4982612395500f4fdac164fefe96b4293fa7b49bf41c7211a69c4dddefb61f20f3a345a3d29a
- languageName: node
- linkType: hard
-
-"@electron-forge/maker-squirrel@npm:^6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/maker-squirrel@npm:6.2.1"
- dependencies:
- "@electron-forge/maker-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- electron-winstaller: ^5.0.0
- fs-extra: ^10.0.0
- dependenciesMeta:
- electron-winstaller:
- optional: true
- checksum: 62f496bbeb7bc7690b9b509689ddc467920d12fe2e263064bebf1692fc91b9d68641623dec2b2e0bb3c959f0086681dea64ab53f80a21f875ca2c3c3e1935b99
- languageName: node
- linkType: hard
-
-"@electron-forge/maker-zip@npm:^6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/maker-zip@npm:6.2.1"
- dependencies:
- "@electron-forge/maker-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- cross-zip: ^4.0.0
- fs-extra: ^10.0.0
- got: ^11.8.5
- checksum: d78468fad71895bf794983e9e7f77aaa95c962a3e59e7d40266ab4aa27f23f65ca288ffaa8db025e7b6c108c41108fabbfbdc6c6297a085be50fa9a06d330a66
- languageName: node
- linkType: hard
-
-"@electron-forge/plugin-auto-unpack-natives@npm:^6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/plugin-auto-unpack-natives@npm:6.2.1"
- dependencies:
- "@electron-forge/plugin-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- checksum: dc8b72ce3646f488975f27ce8190ba55dc269ac40dc4fc4f0d92c303177b6092fcc93a8318546fe2246364e5395ce819d1d5466f09910fa8bb820651e73534fd
- languageName: node
- linkType: hard
-
-"@electron-forge/plugin-base@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/plugin-base@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- checksum: 03e3294201c3308d521651ef19e672ec9e4cdb1c34e273bc4451fc7fdc7b917036cf9ad4a21e105ca8082276e5d7477ab8cd4d5bef5fdda9025d12b657c04e26
- languageName: node
- linkType: hard
-
-"@electron-forge/plugin-vite@npm:^6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/plugin-vite@npm:6.2.1"
- dependencies:
- "@electron-forge/core-utils": 6.2.1
- "@electron-forge/plugin-base": 6.2.1
- "@electron-forge/shared-types": 6.2.1
- "@electron-forge/web-multi-logger": 6.2.1
- chalk: ^4.0.0
- debug: ^4.3.1
- vite: ^4.1.1
- checksum: c96b3d759352b39c7d07679192c86f3b2e5005844bad1c43f6e7f9709028bc89c194fdd754b099209ab17a28a510b3a465d8bb77bcda85f101f8d63565a9f006
- languageName: node
- linkType: hard
-
-"@electron-forge/publisher-base@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/publisher-base@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- checksum: 787d11db87b44c89732b373313d597f4b7e555b709e944a998318aaa8f81c65f91459ace79046798c0d2fa5573da02be3c0f45541e55dbf83e09afde32c4d5dc
- languageName: node
- linkType: hard
-
-"@electron-forge/shared-types@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/shared-types@npm:6.2.1"
- dependencies:
- "@electron/rebuild": ^3.2.10
- electron-packager: ^17.1.1
- listr2: ^5.0.3
- checksum: 524c27b9d40f5b085c4a624aa24c97499c47cc632ab2cf17a4de57c52ebdaf59d842231082a7b7549387df958e8e8dd512082c03e504ad56f3c62d600a44619e
- languageName: node
- linkType: hard
-
-"@electron-forge/template-base@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/template-base@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- "@malept/cross-spawn-promise": ^2.0.0
- debug: ^4.3.1
- fs-extra: ^10.0.0
- username: ^5.1.0
- checksum: 3f826cd48bfdf91b1b8c85ece81d689f9e37d83c247851c4bbfba4adbaf5da87dca72c8c329b4cd64e408067161eca45ef929c5f617b5297c7a7bc25fc79057a
- languageName: node
- linkType: hard
-
-"@electron-forge/template-vite@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/template-vite@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- "@electron-forge/template-base": 6.2.1
- fs-extra: ^10.0.0
- checksum: 878b90b71f05956be7df253d890af4753bbe2043c62b08fa2c0ddcc0dd706921a1c57899b11bbb0ea4f86e4c71f245b171d7e9b484b2842638d6d09294d23d62
- languageName: node
- linkType: hard
-
-"@electron-forge/template-webpack-typescript@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/template-webpack-typescript@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- "@electron-forge/template-base": 6.2.1
- fs-extra: ^10.0.0
- checksum: 0770b9730a15e0ded37a951e1859f588d8390af9c34600895125bae34536c407e39c0c31ba76a1f2c49c7639d8fbd26d64293e54a2870b75e16cdb196ec9e08e
- languageName: node
- linkType: hard
-
-"@electron-forge/template-webpack@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/template-webpack@npm:6.2.1"
- dependencies:
- "@electron-forge/shared-types": 6.2.1
- "@electron-forge/template-base": 6.2.1
- fs-extra: ^10.0.0
- checksum: 67de5c342e458a3d2dceb3413d05085919efa8a0767c31c1c3024c5298962f8fcd4c32f062a236aab75d0147d593173fdc9fc33ef5bb07d0c14716e3e93592a6
- languageName: node
- linkType: hard
-
-"@electron-forge/web-multi-logger@npm:6.2.1":
- version: 6.2.1
- resolution: "@electron-forge/web-multi-logger@npm:6.2.1"
- dependencies:
- express: ^4.17.1
- express-ws: ^5.0.2
- xterm: ^4.9.0
- xterm-addon-fit: ^0.5.0
- xterm-addon-search: ^0.8.0
- checksum: 81d952c96d06e8773254769f7f5caf41ec35f6e7fd1d8ee76dfa133023fa5e12602582c9d7a8441daafe3011eba25d32bad671d8a21285c48997dbd2487ab287
- languageName: node
- linkType: hard
-
-"@electron/asar@npm:^3.2.1":
- version: 3.2.4
- resolution: "@electron/asar@npm:3.2.4"
- dependencies:
- chromium-pickle-js: ^0.2.0
- commander: ^5.0.0
- glob: ^7.1.6
- minimatch: ^3.0.4
- bin:
- asar: bin/asar.js
- checksum: 06e3e8fe7c894f7e7727410af5a9957ec77088f775b22441acf4ef718a9e6642a4dc1672f77ee1ce325fc367c8d59ac1e02f7db07869c8ced8a00132a3b54643
- languageName: node
- linkType: hard
-
-"@electron/get@npm:^2.0.0":
- version: 2.0.2
- resolution: "@electron/get@npm:2.0.2"
- dependencies:
- debug: ^4.1.1
- env-paths: ^2.2.0
- fs-extra: ^8.1.0
- global-agent: ^3.0.0
- got: ^11.8.5
- progress: ^2.0.3
- semver: ^6.2.0
- sumchecker: ^3.0.1
- dependenciesMeta:
- global-agent:
- optional: true
- checksum: 900845cc0b31b54761fc9b0ada2dea1e999e59aacc48999d53903bcb7c9a0a7356b5fe736cf610b2a56c5a21f5a3c0e083b2ed2b7e52c36a4d0f420d4b5ec268
- languageName: node
- linkType: hard
-
-"@electron/notarize@npm:^1.2.3":
- version: 1.2.4
- resolution: "@electron/notarize@npm:1.2.4"
- dependencies:
- debug: ^4.1.1
- fs-extra: ^9.0.1
- checksum: 3aa19fb247f9297b96a25f1a082f552e0c78a726ddfc98de9cdd4e4b092fc36fe07d680b762dd5a2bceda97b1044d3a0e6d9eadc5022f7c329a1fcf081133c9b
- languageName: node
- linkType: hard
-
-"@electron/osx-sign@npm:^1.0.1":
- version: 1.0.4
- resolution: "@electron/osx-sign@npm:1.0.4"
- dependencies:
- compare-version: ^0.1.2
- debug: ^4.3.4
- fs-extra: ^10.0.0
- isbinaryfile: ^4.0.8
- minimist: ^1.2.6
- plist: ^3.0.5
- bin:
- electron-osx-flat: bin/electron-osx-flat.js
- electron-osx-sign: bin/electron-osx-sign.js
- checksum: 0d7382922eabd06ee53b538e15050c7662773ba3fd07cc51ee86f5ec63872685c3b6c8678c967afe7efbee1b393d555fb5553137f7a76af514b30d102568d63e
- languageName: node
- linkType: hard
-
-"@electron/rebuild@npm:^3.2.10":
- version: 3.2.13
- resolution: "@electron/rebuild@npm:3.2.13"
- dependencies:
- "@malept/cross-spawn-promise": ^2.0.0
- chalk: ^4.0.0
- debug: ^4.1.1
- detect-libc: ^2.0.1
- fs-extra: ^10.0.0
- got: ^11.7.0
- node-abi: ^3.0.0
- node-api-version: ^0.1.4
- node-gyp: ^9.0.0
- ora: ^5.1.0
- semver: ^7.3.5
- tar: ^6.0.5
- yargs: ^17.0.1
- bin:
- electron-rebuild: lib/cli.js
- checksum: 79ce6323fa95cab75dc1edb52540c8dd367db9ab084ca94fefde1a46699139b3cee3f5449b7b3b5b9b529887d9f3fabe1689a738351b716e3090e636296c3b1b
- languageName: node
- linkType: hard
-
-"@electron/universal@npm:^1.3.2":
- version: 1.4.1
- resolution: "@electron/universal@npm:1.4.1"
- dependencies:
- "@electron/asar": ^3.2.1
- "@malept/cross-spawn-promise": ^1.1.0
- debug: ^4.3.1
- dir-compare: ^3.0.0
- fs-extra: ^9.0.1
- minimatch: ^3.0.4
- plist: ^3.0.4
- checksum: 257f3a25a4f940ccbe601a0f3a2a925a28657bc3c5fc46018980b771825834665d184e5ce75cfa0b8639525a0bdbb7f0bc02e69e2d4fb044add64638db4d48a4
- languageName: node
- linkType: hard
-
-"@esbuild/android-arm64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/android-arm64@npm:0.18.14"
- conditions: os=android & cpu=arm64
- languageName: node
- linkType: hard
-
-"@esbuild/android-arm@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/android-arm@npm:0.18.14"
- conditions: os=android & cpu=arm
- languageName: node
- linkType: hard
-
-"@esbuild/android-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/android-x64@npm:0.18.14"
- conditions: os=android & cpu=x64
- languageName: node
- linkType: hard
-
-"@esbuild/darwin-arm64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/darwin-arm64@npm:0.18.14"
- conditions: os=darwin & cpu=arm64
- languageName: node
- linkType: hard
-
-"@esbuild/darwin-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/darwin-x64@npm:0.18.14"
- conditions: os=darwin & cpu=x64
- languageName: node
- linkType: hard
-
-"@esbuild/freebsd-arm64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/freebsd-arm64@npm:0.18.14"
- conditions: os=freebsd & cpu=arm64
- languageName: node
- linkType: hard
-
-"@esbuild/freebsd-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/freebsd-x64@npm:0.18.14"
- conditions: os=freebsd & cpu=x64
- languageName: node
- linkType: hard
-
-"@esbuild/linux-arm64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-arm64@npm:0.18.14"
- conditions: os=linux & cpu=arm64
- languageName: node
- linkType: hard
-
-"@esbuild/linux-arm@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-arm@npm:0.18.14"
- conditions: os=linux & cpu=arm
- languageName: node
- linkType: hard
-
-"@esbuild/linux-ia32@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-ia32@npm:0.18.14"
- conditions: os=linux & cpu=ia32
- languageName: node
- linkType: hard
-
-"@esbuild/linux-loong64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-loong64@npm:0.18.14"
- conditions: os=linux & cpu=loong64
- languageName: node
- linkType: hard
-
-"@esbuild/linux-mips64el@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-mips64el@npm:0.18.14"
- conditions: os=linux & cpu=mips64el
- languageName: node
- linkType: hard
-
-"@esbuild/linux-ppc64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-ppc64@npm:0.18.14"
- conditions: os=linux & cpu=ppc64
- languageName: node
- linkType: hard
-
-"@esbuild/linux-riscv64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-riscv64@npm:0.18.14"
- conditions: os=linux & cpu=riscv64
- languageName: node
- linkType: hard
-
-"@esbuild/linux-s390x@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-s390x@npm:0.18.14"
- conditions: os=linux & cpu=s390x
- languageName: node
- linkType: hard
-
-"@esbuild/linux-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/linux-x64@npm:0.18.14"
- conditions: os=linux & cpu=x64
- languageName: node
- linkType: hard
-
-"@esbuild/netbsd-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/netbsd-x64@npm:0.18.14"
- conditions: os=netbsd & cpu=x64
- languageName: node
- linkType: hard
-
-"@esbuild/openbsd-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/openbsd-x64@npm:0.18.14"
- conditions: os=openbsd & cpu=x64
- languageName: node
- linkType: hard
-
-"@esbuild/sunos-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/sunos-x64@npm:0.18.14"
- conditions: os=sunos & cpu=x64
- languageName: node
- linkType: hard
-
-"@esbuild/win32-arm64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/win32-arm64@npm:0.18.14"
- conditions: os=win32 & cpu=arm64
- languageName: node
- linkType: hard
-
-"@esbuild/win32-ia32@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/win32-ia32@npm:0.18.14"
- conditions: os=win32 & cpu=ia32
- languageName: node
- linkType: hard
-
-"@esbuild/win32-x64@npm:0.18.14":
- version: 0.18.14
- resolution: "@esbuild/win32-x64@npm:0.18.14"
- conditions: os=win32 & cpu=x64
- languageName: node
- linkType: hard
-
-"@isaacs/cliui@npm:^8.0.2":
- version: 8.0.2
- resolution: "@isaacs/cliui@npm:8.0.2"
- dependencies:
- string-width: ^5.1.2
- string-width-cjs: "npm:string-width@^4.2.0"
- strip-ansi: ^7.0.1
- strip-ansi-cjs: "npm:strip-ansi@^6.0.1"
- wrap-ansi: ^8.1.0
- wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0"
- checksum: 4a473b9b32a7d4d3cfb7a614226e555091ff0c5a29a1734c28c72a182c2f6699b26fc6b5c2131dfd841e86b185aea714c72201d7c98c2fba5f17709333a67aeb
- languageName: node
- linkType: hard
-
-"@malept/cross-spawn-promise@npm:^1.0.0, @malept/cross-spawn-promise@npm:^1.1.0":
- version: 1.1.1
- resolution: "@malept/cross-spawn-promise@npm:1.1.1"
- dependencies:
- cross-spawn: ^7.0.1
- checksum: 1aa468f9ff3aa59dbaa720731ddf9c1928228b6844358d8821b86628953e0608420e88c6366d85af35acad73b1addaa472026a1836ad3fec34813eb38b2bd25a
- languageName: node
- linkType: hard
-
-"@malept/cross-spawn-promise@npm:^2.0.0":
- version: 2.0.0
- resolution: "@malept/cross-spawn-promise@npm:2.0.0"
- dependencies:
- cross-spawn: ^7.0.1
- checksum: 9016a6674842c161b6949d7876e655874ca2d7f6a4fd88a73147d2abde0dcb3981c5dd9714e721e40f92e953ba16e18d7ee3fc94e8b1aae9b5922c582cd320da
- languageName: node
- linkType: hard
-
-"@nodelib/fs.scandir@npm:2.1.5":
- version: 2.1.5
- resolution: "@nodelib/fs.scandir@npm:2.1.5"
- dependencies:
- "@nodelib/fs.stat": 2.0.5
- run-parallel: ^1.1.9
- checksum: a970d595bd23c66c880e0ef1817791432dbb7acbb8d44b7e7d0e7a22f4521260d4a83f7f9fd61d44fda4610105577f8f58a60718105fb38352baed612fd79e59
- languageName: node
- linkType: hard
-
-"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2":
- version: 2.0.5
- resolution: "@nodelib/fs.stat@npm:2.0.5"
- checksum: 012480b5ca9d97bff9261571dbbec7bbc6033f69cc92908bc1ecfad0792361a5a1994bc48674b9ef76419d056a03efadfce5a6cf6dbc0a36559571a7a483f6f0
- languageName: node
- linkType: hard
-
-"@nodelib/fs.walk@npm:^1.2.3":
- version: 1.2.8
- resolution: "@nodelib/fs.walk@npm:1.2.8"
- dependencies:
- "@nodelib/fs.scandir": 2.1.5
- fastq: ^1.6.0
- checksum: 190c643f156d8f8f277bf2a6078af1ffde1fd43f498f187c2db24d35b4b4b5785c02c7dc52e356497b9a1b65b13edc996de08de0b961c32844364da02986dc53
- languageName: node
- linkType: hard
-
-"@npmcli/fs@npm:^3.1.0":
- version: 3.1.0
- resolution: "@npmcli/fs@npm:3.1.0"
- dependencies:
- semver: ^7.3.5
- checksum: a50a6818de5fc557d0b0e6f50ec780a7a02ab8ad07e5ac8b16bf519e0ad60a144ac64f97d05c443c3367235d337182e1d012bbac0eb8dbae8dc7b40b193efd0e
- languageName: node
- linkType: hard
-
-"@pkgjs/parseargs@npm:^0.11.0":
- version: 0.11.0
- resolution: "@pkgjs/parseargs@npm:0.11.0"
- checksum: 6ad6a00fc4f2f2cfc6bff76fb1d88b8ee20bc0601e18ebb01b6d4be583733a860239a521a7fbca73b612e66705078809483549d2b18f370eb346c5155c8e4a0f
- languageName: node
- linkType: hard
-
-"@sindresorhus/is@npm:^4.0.0":
- version: 4.6.0
- resolution: "@sindresorhus/is@npm:4.6.0"
- checksum: 83839f13da2c29d55c97abc3bc2c55b250d33a0447554997a85c539e058e57b8da092da396e252b11ec24a0279a0bed1f537fa26302209327060643e327f81d2
- languageName: node
- linkType: hard
-
-"@szmarczak/http-timer@npm:^4.0.5":
- version: 4.0.6
- resolution: "@szmarczak/http-timer@npm:4.0.6"
- dependencies:
- defer-to-connect: ^2.0.0
- checksum: c29df3bcec6fc3bdec2b17981d89d9c9fc9bd7d0c9bcfe92821dc533f4440bc890ccde79971838b4ceed1921d456973c4180d7175ee1d0023ad0562240a58d95
- languageName: node
- linkType: hard
-
-"@table-group/electron@workspace:.":
- version: 0.0.0-use.local
- resolution: "@table-group/electron@workspace:."
- dependencies:
- "@electron-forge/cli": ^6.2.1
- "@electron-forge/maker-deb": ^6.2.1
- "@electron-forge/maker-dmg": 6.2.1
- "@electron-forge/maker-rpm": ^6.2.1
- "@electron-forge/maker-squirrel": ^6.2.1
- "@electron-forge/maker-zip": ^6.2.1
- "@electron-forge/plugin-auto-unpack-natives": ^6.2.1
- "@electron-forge/plugin-vite": ^6.2.1
- electron: 25.3.0
- electron-squirrel-startup: 1.0.0
- is-port-reachable: 3.1.0
- languageName: unknown
- linkType: soft
-
-"@tootallnate/once@npm:2":
- version: 2.0.0
- resolution: "@tootallnate/once@npm:2.0.0"
- checksum: ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8
- languageName: node
- linkType: hard
-
-"@types/cacheable-request@npm:^6.0.1":
- version: 6.0.3
- resolution: "@types/cacheable-request@npm:6.0.3"
- dependencies:
- "@types/http-cache-semantics": "*"
- "@types/keyv": ^3.1.4
- "@types/node": "*"
- "@types/responselike": ^1.0.0
- checksum: d9b26403fe65ce6b0cb3720b7030104c352bcb37e4fac2a7089a25a97de59c355fa08940658751f2f347a8512aa9d18fdb66ab3ade835975b2f454f2d5befbd9
- languageName: node
- linkType: hard
-
-"@types/fs-extra@npm:^9.0.1":
- version: 9.0.13
- resolution: "@types/fs-extra@npm:9.0.13"
- dependencies:
- "@types/node": "*"
- checksum: add79e212acd5ac76b97b9045834e03a7996aef60a814185e0459088fd290519a3c1620865d588fa36c4498bf614210d2a703af5cf80aa1dbc125db78f6edac3
- languageName: node
- linkType: hard
-
-"@types/glob@npm:^7.1.1":
- version: 7.2.0
- resolution: "@types/glob@npm:7.2.0"
- dependencies:
- "@types/minimatch": "*"
- "@types/node": "*"
- checksum: 6ae717fedfdfdad25f3d5a568323926c64f52ef35897bcac8aca8e19bc50c0bd84630bbd063e5d52078b2137d8e7d3c26eabebd1a2f03ff350fff8a91e79fc19
- languageName: node
- linkType: hard
-
-"@types/http-cache-semantics@npm:*":
- version: 4.0.1
- resolution: "@types/http-cache-semantics@npm:4.0.1"
- checksum: 1048aacf627829f0d5f00184e16548205cd9f964bf0841c29b36bc504509230c40bc57c39778703a1c965a6f5b416ae2cbf4c1d4589c889d2838dd9dbfccf6e9
- languageName: node
- linkType: hard
-
-"@types/keyv@npm:^3.1.4":
- version: 3.1.4
- resolution: "@types/keyv@npm:3.1.4"
- dependencies:
- "@types/node": "*"
- checksum: e009a2bfb50e90ca9b7c6e8f648f8464067271fd99116f881073fa6fa76dc8d0133181dd65e6614d5fb1220d671d67b0124aef7d97dc02d7e342ab143a47779d
- languageName: node
- linkType: hard
-
-"@types/minimatch@npm:*":
- version: 5.1.2
- resolution: "@types/minimatch@npm:5.1.2"
- checksum: 0391a282860c7cb6fe262c12b99564732401bdaa5e395bee9ca323c312c1a0f45efbf34dce974682036e857db59a5c9b1da522f3d6055aeead7097264c8705a8
- languageName: node
- linkType: hard
-
-"@types/node@npm:*":
- version: 20.4.2
- resolution: "@types/node@npm:20.4.2"
- checksum: 99e544ea7560d51f01f95627fc40394c24a13da8f041121a0da13e4ef0a2aa332932eaf9a5e8d0e30d1c07106e96a183be392cbba62e8cf0bf6a085d5c0f4149
- languageName: node
- linkType: hard
-
-"@types/node@npm:^18.11.18":
- version: 18.16.19
- resolution: "@types/node@npm:18.16.19"
- checksum: 63c31f09616508aa7135380a4c79470a897b75f9ff3a70eb069e534dfabdec3f32fb0f9df5939127f1086614d980ddea0fa5e8cc29a49103c4f74cd687618aaf
- languageName: node
- linkType: hard
-
-"@types/responselike@npm:^1.0.0":
- version: 1.0.0
- resolution: "@types/responselike@npm:1.0.0"
- dependencies:
- "@types/node": "*"
- checksum: e99fc7cc6265407987b30deda54c1c24bb1478803faf6037557a774b2f034c5b097ffd65847daa87e82a61a250d919f35c3588654b0fdaa816906650f596d1b0
- languageName: node
- linkType: hard
-
-"@types/yauzl@npm:^2.9.1":
- version: 2.10.0
- resolution: "@types/yauzl@npm:2.10.0"
- dependencies:
- "@types/node": "*"
- checksum: 55d27ae5d346ea260e40121675c24e112ef0247649073848e5d4e03182713ae4ec8142b98f61a1c6cbe7d3b72fa99bbadb65d8b01873e5e605cdc30f1ff70ef2
- languageName: node
- linkType: hard
-
-"@xmldom/xmldom@npm:^0.8.8":
- version: 0.8.10
- resolution: "@xmldom/xmldom@npm:0.8.10"
- checksum: 4c136aec31fb3b49aaa53b6fcbfe524d02a1dc0d8e17ee35bd3bf35e9ce1344560481cd1efd086ad1a4821541482528672306d5e37cdbd187f33d7fadd3e2cf0
- languageName: node
- linkType: hard
-
-"abbrev@npm:^1.0.0":
- version: 1.1.1
- resolution: "abbrev@npm:1.1.1"
- checksum: a4a97ec07d7ea112c517036882b2ac22f3109b7b19077dc656316d07d308438aac28e4d9746dc4d84bf6b1e75b4a7b0a5f3cb30592419f128ca9a8cee3bcfa17
- languageName: node
- linkType: hard
-
-"accepts@npm:~1.3.8":
- version: 1.3.8
- resolution: "accepts@npm:1.3.8"
- dependencies:
- mime-types: ~2.1.34
- negotiator: 0.6.3
- checksum: 50c43d32e7b50285ebe84b613ee4a3aa426715a7d131b65b786e2ead0fd76b6b60091b9916d3478a75f11f162628a2139991b6c03ab3f1d9ab7c86075dc8eab4
- languageName: node
- linkType: hard
-
-"agent-base@npm:6, agent-base@npm:^6.0.2":
- version: 6.0.2
- resolution: "agent-base@npm:6.0.2"
- dependencies:
- debug: 4
- checksum: f52b6872cc96fd5f622071b71ef200e01c7c4c454ee68bc9accca90c98cfb39f2810e3e9aa330435835eedc8c23f4f8a15267f67c6e245d2b33757575bdac49d
- languageName: node
- linkType: hard
-
-"agentkeepalive@npm:^4.2.1":
- version: 4.3.0
- resolution: "agentkeepalive@npm:4.3.0"
- dependencies:
- debug: ^4.1.0
- depd: ^2.0.0
- humanize-ms: ^1.2.1
- checksum: 982453aa44c11a06826c836025e5162c846e1200adb56f2d075400da7d32d87021b3b0a58768d949d824811f5654223d5a8a3dad120921a2439625eb847c6260
- languageName: node
- linkType: hard
-
-"aggregate-error@npm:^3.0.0":
- version: 3.1.0
- resolution: "aggregate-error@npm:3.1.0"
- dependencies:
- clean-stack: ^2.0.0
- indent-string: ^4.0.0
- checksum: 1101a33f21baa27a2fa8e04b698271e64616b886795fd43c31068c07533c7b3facfcaf4e9e0cab3624bd88f729a592f1c901a1a229c9e490eafce411a8644b79
- languageName: node
- linkType: hard
-
-"ansi-escapes@npm:^4.3.0":
- version: 4.3.2
- resolution: "ansi-escapes@npm:4.3.2"
- dependencies:
- type-fest: ^0.21.3
- checksum: 93111c42189c0a6bed9cdb4d7f2829548e943827ee8479c74d6e0b22ee127b2a21d3f8b5ca57723b8ef78ce011fbfc2784350eb2bde3ccfccf2f575fa8489815
- languageName: node
- linkType: hard
-
-"ansi-regex@npm:^5.0.1":
- version: 5.0.1
- resolution: "ansi-regex@npm:5.0.1"
- checksum: 2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b
- languageName: node
- linkType: hard
-
-"ansi-regex@npm:^6.0.1":
- version: 6.0.1
- resolution: "ansi-regex@npm:6.0.1"
- checksum: 1ff8b7667cded1de4fa2c9ae283e979fc87036864317da86a2e546725f96406746411d0d85e87a2d12fa5abd715d90006de7fa4fa0477c92321ad3b4c7d4e169
- languageName: node
- linkType: hard
-
-"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0":
- version: 4.3.0
- resolution: "ansi-styles@npm:4.3.0"
- dependencies:
- color-convert: ^2.0.1
- checksum: 513b44c3b2105dd14cc42a19271e80f386466c4be574bccf60b627432f9198571ebf4ab1e4c3ba17347658f4ee1711c163d574248c0c1cdc2d5917a0ad582ec4
- languageName: node
- linkType: hard
-
-"ansi-styles@npm:^6.1.0":
- version: 6.2.1
- resolution: "ansi-styles@npm:6.2.1"
- checksum: ef940f2f0ced1a6347398da88a91da7930c33ecac3c77b72c5905f8b8fe402c52e6fde304ff5347f616e27a742da3f1dc76de98f6866c69251ad0b07a66776d9
- languageName: node
- linkType: hard
-
-"appdmg@npm:^0.6.4":
- version: 0.6.6
- resolution: "appdmg@npm:0.6.6"
- dependencies:
- async: ^1.4.2
- ds-store: ^0.1.5
- execa: ^1.0.0
- fs-temp: ^1.0.0
- fs-xattr: ^0.3.0
- image-size: ^0.7.4
- is-my-json-valid: ^2.20.0
- minimist: ^1.1.3
- parse-color: ^1.0.0
- path-exists: ^4.0.0
- repeat-string: ^1.5.4
- bin:
- appdmg: bin/appdmg.js
- conditions: os=darwin
- languageName: node
- linkType: hard
-
-"aproba@npm:^1.0.3 || ^2.0.0":
- version: 2.0.0
- resolution: "aproba@npm:2.0.0"
- checksum: 5615cadcfb45289eea63f8afd064ab656006361020e1735112e346593856f87435e02d8dcc7ff0d11928bc7d425f27bc7c2a84f6c0b35ab0ff659c814c138a24
- languageName: node
- linkType: hard
-
-"are-we-there-yet@npm:^3.0.0":
- version: 3.0.1
- resolution: "are-we-there-yet@npm:3.0.1"
- dependencies:
- delegates: ^1.0.0
- readable-stream: ^3.6.0
- checksum: 52590c24860fa7173bedeb69a4c05fb573473e860197f618b9a28432ee4379049336727ae3a1f9c4cb083114601c1140cee578376164d0e651217a9843f9fe83
- languageName: node
- linkType: hard
-
-"array-flatten@npm:1.1.1":
- version: 1.1.1
- resolution: "array-flatten@npm:1.1.1"
- checksum: a9925bf3512d9dce202112965de90c222cd59a4fbfce68a0951d25d965cf44642931f40aac72309c41f12df19afa010ecadceb07cfff9ccc1621e99d89ab5f3b
- languageName: node
- linkType: hard
-
-"asar@npm:^3.0.0":
- version: 3.2.0
- resolution: "asar@npm:3.2.0"
- dependencies:
- "@types/glob": ^7.1.1
- chromium-pickle-js: ^0.2.0
- commander: ^5.0.0
- glob: ^7.1.6
- minimatch: ^3.0.4
- dependenciesMeta:
- "@types/glob":
- optional: true
- bin:
- asar: bin/asar.js
- checksum: f7d30b45970b053252ac124230bf319459d0728d7f6dedbe2f765cd2a83792d5a716d2c3f2861ceda69372b401f335e1f46460335169eadd0e91a0904a4f5a15
- languageName: node
- linkType: hard
-
-"astral-regex@npm:^2.0.0":
- version: 2.0.0
- resolution: "astral-regex@npm:2.0.0"
- checksum: 876231688c66400473ba505731df37ea436e574dd524520294cc3bbc54ea40334865e01fa0d074d74d036ee874ee7e62f486ea38bc421ee8e6a871c06f011766
- languageName: node
- linkType: hard
-
-"async@npm:^1.4.2":
- version: 1.5.2
- resolution: "async@npm:1.5.2"
- checksum: fe5d6214d8f15bd51eee5ae8ec5079b228b86d2d595f47b16369dec2e11b3ff75a567bb5f70d12d79006665fbbb7ee0a7ec0e388524eefd454ecbe651c124ebd
- languageName: node
- linkType: hard
-
-"at-least-node@npm:^1.0.0":
- version: 1.0.0
- resolution: "at-least-node@npm:1.0.0"
- checksum: 463e2f8e43384f1afb54bc68485c436d7622acec08b6fad269b421cb1d29cebb5af751426793d0961ed243146fe4dc983402f6d5a51b720b277818dbf6f2e49e
- languageName: node
- linkType: hard
-
-"author-regex@npm:^1.0.0":
- version: 1.0.0
- resolution: "author-regex@npm:1.0.0"
- checksum: 9ad8bffb02978c7a53cbe0b0ff55988fa9f4429797b2c3783f0964df6ee198663285d7f0f3f981766a8c4fe91633ba62582244c1b54d50096007a0fe115b6898
- languageName: node
- linkType: hard
-
-"balanced-match@npm:^1.0.0":
- version: 1.0.2
- resolution: "balanced-match@npm:1.0.2"
- checksum: 9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65
- languageName: node
- linkType: hard
-
-"base32-encode@npm:^0.1.0 || ^1.0.0":
- version: 1.2.0
- resolution: "base32-encode@npm:1.2.0"
- dependencies:
- to-data-view: ^1.1.0
- checksum: b8df667599d50b2c9fca206fcab9bf6500d2e980b14da204eb7de5ce978c99e4874e8138d109bd88d5bca1bfb5ae83926bca37b084d2c9842f8acb12b4b839d9
- languageName: node
- linkType: hard
-
-"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
- version: 1.5.1
- resolution: "base64-js@npm:1.5.1"
- checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
- languageName: node
- linkType: hard
-
-"bl@npm:^4.1.0":
- version: 4.1.0
- resolution: "bl@npm:4.1.0"
- dependencies:
- buffer: ^5.5.0
- inherits: ^2.0.4
- readable-stream: ^3.4.0
- checksum: 9e8521fa7e83aa9427c6f8ccdcba6e8167ef30cc9a22df26effcc5ab682ef91d2cbc23a239f945d099289e4bbcfae7a192e9c28c84c6202e710a0dfec3722662
- languageName: node
- linkType: hard
-
-"bluebird@npm:^3.1.1":
- version: 3.7.2
- resolution: "bluebird@npm:3.7.2"
- checksum: 869417503c722e7dc54ca46715f70e15f4d9c602a423a02c825570862d12935be59ed9c7ba34a9b31f186c017c23cac6b54e35446f8353059c101da73eac22ef
- languageName: node
- linkType: hard
-
-"body-parser@npm:1.20.1":
- version: 1.20.1
- resolution: "body-parser@npm:1.20.1"
- dependencies:
- bytes: 3.1.2
- content-type: ~1.0.4
- debug: 2.6.9
- depd: 2.0.0
- destroy: 1.2.0
- http-errors: 2.0.0
- iconv-lite: 0.4.24
- on-finished: 2.4.1
- qs: 6.11.0
- raw-body: 2.5.1
- type-is: ~1.6.18
- unpipe: 1.0.0
- checksum: f1050dbac3bede6a78f0b87947a8d548ce43f91ccc718a50dd774f3c81f2d8b04693e52acf62659fad23101827dd318da1fb1363444ff9a8482b886a3e4a5266
- languageName: node
- linkType: hard
-
-"boolean@npm:^3.0.1":
- version: 3.2.0
- resolution: "boolean@npm:3.2.0"
- checksum: fb29535b8bf710ef45279677a86d14f5185d604557204abd2ca5fa3fb2a5c80e04d695c8dbf13ab269991977a79bb6c04b048220a6b2a3849853faa94f4a7d77
- languageName: node
- linkType: hard
-
-"bplist-creator@npm:~0.0.3":
- version: 0.0.8
- resolution: "bplist-creator@npm:0.0.8"
- dependencies:
- stream-buffers: ~2.2.0
- checksum: 7a98c7fb3c1b505a0667abd0f8c976bc01c4437fbb52cb902076a3aea3523e8d44111e21a4228c4c3b307d1c4a727968ed02bd91daf0aea7efed5081db92fb95
- languageName: node
- linkType: hard
-
-"brace-expansion@npm:^1.1.7":
- version: 1.1.11
- resolution: "brace-expansion@npm:1.1.11"
- dependencies:
- balanced-match: ^1.0.0
- concat-map: 0.0.1
- checksum: faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07
- languageName: node
- linkType: hard
-
-"brace-expansion@npm:^2.0.1":
- version: 2.0.1
- resolution: "brace-expansion@npm:2.0.1"
- dependencies:
- balanced-match: ^1.0.0
- checksum: a61e7cd2e8a8505e9f0036b3b6108ba5e926b4b55089eeb5550cd04a471fe216c96d4fe7e4c7f995c728c554ae20ddfc4244cad10aef255e72b62930afd233d1
- languageName: node
- linkType: hard
-
-"braces@npm:^3.0.2":
- version: 3.0.2
- resolution: "braces@npm:3.0.2"
- dependencies:
- fill-range: ^7.0.1
- checksum: e2a8e769a863f3d4ee887b5fe21f63193a891c68b612ddb4b68d82d1b5f3ff9073af066c343e9867a393fe4c2555dcb33e89b937195feb9c1613d259edfcd459
- languageName: node
- linkType: hard
-
-"buffer-crc32@npm:~0.2.3":
- version: 0.2.13
- resolution: "buffer-crc32@npm:0.2.13"
- checksum: 06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c
- languageName: node
- linkType: hard
-
-"buffer-equal@npm:^1.0.0":
- version: 1.0.1
- resolution: "buffer-equal@npm:1.0.1"
- checksum: 6ead0f976726c4e2fb6f2e82419983f4a99cbf2cca1f1e107e16c23c4d91d9046c732dd29b63fc6ac194354f74fa107e8e94946ef2527812d83cde1d5a006309
- languageName: node
- linkType: hard
-
-"buffer-from@npm:^1.0.0":
- version: 1.1.2
- resolution: "buffer-from@npm:1.1.2"
- checksum: 0448524a562b37d4d7ed9efd91685a5b77a50672c556ea254ac9a6d30e3403a517d8981f10e565db24e8339413b43c97ca2951f10e399c6125a0d8911f5679bb
- languageName: node
- linkType: hard
-
-"buffer@npm:^5.5.0":
- version: 5.7.1
- resolution: "buffer@npm:5.7.1"
- dependencies:
- base64-js: ^1.3.1
- ieee754: ^1.1.13
- checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84
- languageName: node
- linkType: hard
-
-"bytes@npm:3.1.2":
- version: 3.1.2
- resolution: "bytes@npm:3.1.2"
- checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e
- languageName: node
- linkType: hard
-
-"cacache@npm:^17.0.0":
- version: 17.1.3
- resolution: "cacache@npm:17.1.3"
- dependencies:
- "@npmcli/fs": ^3.1.0
- fs-minipass: ^3.0.0
- glob: ^10.2.2
- lru-cache: ^7.7.1
- minipass: ^5.0.0
- minipass-collect: ^1.0.2
- minipass-flush: ^1.0.5
- minipass-pipeline: ^1.2.4
- p-map: ^4.0.0
- ssri: ^10.0.0
- tar: ^6.1.11
- unique-filename: ^3.0.0
- checksum: 385756781e1e21af089160d89d7462b7ed9883c978e848c7075b90b73cb823680e66092d61513050164588387d2ca87dd6d910e28d64bc13a9ac82cd8580c796
- languageName: node
- linkType: hard
-
-"cacheable-lookup@npm:^5.0.3":
- version: 5.0.4
- resolution: "cacheable-lookup@npm:5.0.4"
- checksum: 763e02cf9196bc9afccacd8c418d942fc2677f22261969a4c2c2e760fa44a2351a81557bd908291c3921fe9beb10b976ba8fa50c5ca837c5a0dd945f16468f2d
- languageName: node
- linkType: hard
-
-"cacheable-request@npm:^7.0.2":
- version: 7.0.4
- resolution: "cacheable-request@npm:7.0.4"
- dependencies:
- clone-response: ^1.0.2
- get-stream: ^5.1.0
- http-cache-semantics: ^4.0.0
- keyv: ^4.0.0
- lowercase-keys: ^2.0.0
- normalize-url: ^6.0.1
- responselike: ^2.0.0
- checksum: 0de9df773fd4e7dd9bd118959878f8f2163867e2e1ab3575ffbecbe6e75e80513dd0c68ba30005e5e5a7b377cc6162bbc00ab1db019bb4e9cb3c2f3f7a6f1ee4
- languageName: node
- linkType: hard
-
-"call-bind@npm:^1.0.0":
- version: 1.0.2
- resolution: "call-bind@npm:1.0.2"
- dependencies:
- function-bind: ^1.1.1
- get-intrinsic: ^1.0.2
- checksum: f8e31de9d19988a4b80f3e704788c4a2d6b6f3d17cfec4f57dc29ced450c53a49270dc66bf0fbd693329ee948dd33e6c90a329519aef17474a4d961e8d6426b0
- languageName: node
- linkType: hard
-
-"camelcase@npm:^5.0.0":
- version: 5.3.1
- resolution: "camelcase@npm:5.3.1"
- checksum: e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b
- languageName: node
- linkType: hard
-
-"chalk@npm:^4.0.0, chalk@npm:^4.1.0":
- version: 4.1.2
- resolution: "chalk@npm:4.1.2"
- dependencies:
- ansi-styles: ^4.1.0
- supports-color: ^7.1.0
- checksum: fe75c9d5c76a7a98d45495b91b2172fa3b7a09e0cc9370e5c8feb1c567b85c4288e2b3fded7cfdd7359ac28d6b3844feb8b82b8686842e93d23c827c417e83fc
- languageName: node
- linkType: hard
-
-"chownr@npm:^2.0.0":
- version: 2.0.0
- resolution: "chownr@npm:2.0.0"
- checksum: c57cf9dd0791e2f18a5ee9c1a299ae6e801ff58fee96dc8bfd0dcb4738a6ce58dd252a3605b1c93c6418fe4f9d5093b28ffbf4d66648cb2a9c67eaef9679be2f
- languageName: node
- linkType: hard
-
-"chromium-pickle-js@npm:^0.2.0":
- version: 0.2.0
- resolution: "chromium-pickle-js@npm:0.2.0"
- checksum: 5ccacc538b0a1ecf3484c8fb3327eae129ceee858db0f64eb0a5ff87bda096a418d0d3e6f6e0967c6334d336a2c7463f7b683ec0e1cafbe736907fa2ee2f58ca
- languageName: node
- linkType: hard
-
-"clean-stack@npm:^2.0.0":
- version: 2.2.0
- resolution: "clean-stack@npm:2.2.0"
- checksum: 2ac8cd2b2f5ec986a3c743935ec85b07bc174d5421a5efc8017e1f146a1cf5f781ae962618f416352103b32c9cd7e203276e8c28241bbe946160cab16149fb68
- languageName: node
- linkType: hard
-
-"cli-cursor@npm:^3.1.0":
- version: 3.1.0
- resolution: "cli-cursor@npm:3.1.0"
- dependencies:
- restore-cursor: ^3.1.0
- checksum: 2692784c6cd2fd85cfdbd11f53aea73a463a6d64a77c3e098b2b4697a20443f430c220629e1ca3b195ea5ac4a97a74c2ee411f3807abf6df2b66211fec0c0a29
- languageName: node
- linkType: hard
-
-"cli-spinners@npm:^2.5.0":
- version: 2.9.0
- resolution: "cli-spinners@npm:2.9.0"
- checksum: a9c56e1f44457d4a9f4f535364e729cb8726198efa9e98990cfd9eda9e220dfa4ba12f92808d1be5e29029cdfead781db82dc8549b97b31c907d55f96aa9b0e2
- languageName: node
- linkType: hard
-
-"cli-truncate@npm:^2.1.0":
- version: 2.1.0
- resolution: "cli-truncate@npm:2.1.0"
- dependencies:
- slice-ansi: ^3.0.0
- string-width: ^4.2.0
- checksum: bf1e4e6195392dc718bf9cd71f317b6300dc4a9191d052f31046b8773230ece4fa09458813bf0e3455a5e68c0690d2ea2c197d14a8b85a7b5e01c97f4b5feb5d
- languageName: node
- linkType: hard
-
-"cliui@npm:^6.0.0":
- version: 6.0.0
- resolution: "cliui@npm:6.0.0"
- dependencies:
- string-width: ^4.2.0
- strip-ansi: ^6.0.0
- wrap-ansi: ^6.2.0
- checksum: 4fcfd26d292c9f00238117f39fc797608292ae36bac2168cfee4c85923817d0607fe21b3329a8621e01aedf512c99b7eaa60e363a671ffd378df6649fb48ae42
- languageName: node
- linkType: hard
-
-"cliui@npm:^7.0.2":
- version: 7.0.4
- resolution: "cliui@npm:7.0.4"
- dependencies:
- string-width: ^4.2.0
- strip-ansi: ^6.0.0
- wrap-ansi: ^7.0.0
- checksum: ce2e8f578a4813806788ac399b9e866297740eecd4ad1823c27fd344d78b22c5f8597d548adbcc46f0573e43e21e751f39446c5a5e804a12aace402b7a315d7f
- languageName: node
- linkType: hard
-
-"cliui@npm:^8.0.1":
- version: 8.0.1
- resolution: "cliui@npm:8.0.1"
- dependencies:
- string-width: ^4.2.0
- strip-ansi: ^6.0.1
- wrap-ansi: ^7.0.0
- checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56
- languageName: node
- linkType: hard
-
-"clone-response@npm:^1.0.2":
- version: 1.0.3
- resolution: "clone-response@npm:1.0.3"
- dependencies:
- mimic-response: ^1.0.0
- checksum: 4e671cac39b11c60aa8ba0a450657194a5d6504df51bca3fac5b3bd0145c4f8e8464898f87c8406b83232e3bc5cca555f51c1f9c8ac023969ebfbf7f6bdabb2e
- languageName: node
- linkType: hard
-
-"clone@npm:^1.0.2":
- version: 1.0.4
- resolution: "clone@npm:1.0.4"
- checksum: d06418b7335897209e77bdd430d04f882189582e67bd1f75a04565f3f07f5b3f119a9d670c943b6697d0afb100f03b866b3b8a1f91d4d02d72c4ecf2bb64b5dd
- languageName: node
- linkType: hard
-
-"color-convert@npm:^2.0.1":
- version: 2.0.1
- resolution: "color-convert@npm:2.0.1"
- dependencies:
- color-name: ~1.1.4
- checksum: 79e6bdb9fd479a205c71d89574fccfb22bd9053bd98c6c4d870d65c132e5e904e6034978e55b43d69fcaa7433af2016ee203ce76eeba9cfa554b373e7f7db336
- languageName: node
- linkType: hard
-
-"color-convert@npm:~0.5.0":
- version: 0.5.3
- resolution: "color-convert@npm:0.5.3"
- checksum: 1074989a2c216d0171a397b870a0d698ef802ab3f9ece72b35bd92c4d20aeab31f222ea525dd5d3fad175a3f256a750eadd14ab882caed0089efc1cb7ba74086
- languageName: node
- linkType: hard
-
-"color-name@npm:~1.1.4":
- version: 1.1.4
- resolution: "color-name@npm:1.1.4"
- checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610
- languageName: node
- linkType: hard
-
-"color-support@npm:^1.1.3":
- version: 1.1.3
- resolution: "color-support@npm:1.1.3"
- bin:
- color-support: bin.js
- checksum: 9b7356817670b9a13a26ca5af1c21615463b500783b739b7634a0c2047c16cef4b2865d7576875c31c3cddf9dd621fa19285e628f20198b233a5cfdda6d0793b
- languageName: node
- linkType: hard
-
-"colorette@npm:^2.0.19":
- version: 2.0.20
- resolution: "colorette@npm:2.0.20"
- checksum: 0c016fea2b91b733eb9f4bcdb580018f52c0bc0979443dad930e5037a968237ac53d9beb98e218d2e9235834f8eebce7f8e080422d6194e957454255bde71d3d
- languageName: node
- linkType: hard
-
-"commander@npm:^4.1.1":
- version: 4.1.1
- resolution: "commander@npm:4.1.1"
- checksum: d7b9913ff92cae20cb577a4ac6fcc121bd6223319e54a40f51a14740a681ad5c574fd29a57da478a5f234a6fa6c52cbf0b7c641353e03c648b1ae85ba670b977
- languageName: node
- linkType: hard
-
-"commander@npm:^5.0.0":
- version: 5.1.0
- resolution: "commander@npm:5.1.0"
- checksum: 0b7fec1712fbcc6230fcb161d8d73b4730fa91a21dc089515489402ad78810547683f058e2a9835929c212fead1d6a6ade70db28bbb03edbc2829a9ab7d69447
- languageName: node
- linkType: hard
-
-"compare-version@npm:^0.1.2":
- version: 0.1.2
- resolution: "compare-version@npm:0.1.2"
- checksum: 0ceaf50b5f912c8eb8eeca19375e617209d200abebd771e9306510166462e6f91ad764f33f210a3058ee27c83f2f001a7a4ca32f509da2d207d0143a3438a020
- languageName: node
- linkType: hard
-
-"concat-map@npm:0.0.1":
- version: 0.0.1
- resolution: "concat-map@npm:0.0.1"
- checksum: 902a9f5d8967a3e2faf138d5cb784b9979bad2e6db5357c5b21c568df4ebe62bcb15108af1b2253744844eb964fc023fbd9afbbbb6ddd0bcc204c6fb5b7bf3af
- languageName: node
- linkType: hard
-
-"console-control-strings@npm:^1.1.0":
- version: 1.1.0
- resolution: "console-control-strings@npm:1.1.0"
- checksum: 8755d76787f94e6cf79ce4666f0c5519906d7f5b02d4b884cf41e11dcd759ed69c57da0670afd9236d229a46e0f9cf519db0cd829c6dca820bb5a5c3def584ed
- languageName: node
- linkType: hard
-
-"content-disposition@npm:0.5.4":
- version: 0.5.4
- resolution: "content-disposition@npm:0.5.4"
- dependencies:
- safe-buffer: 5.2.1
- checksum: afb9d545e296a5171d7574fcad634b2fdf698875f4006a9dd04a3e1333880c5c0c98d47b560d01216fb6505a54a2ba6a843ee3a02ec86d7e911e8315255f56c3
- languageName: node
- linkType: hard
-
-"content-type@npm:~1.0.4":
- version: 1.0.5
- resolution: "content-type@npm:1.0.5"
- checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766
- languageName: node
- linkType: hard
-
-"cookie-signature@npm:1.0.6":
- version: 1.0.6
- resolution: "cookie-signature@npm:1.0.6"
- checksum: f4e1b0a98a27a0e6e66fd7ea4e4e9d8e038f624058371bf4499cfcd8f3980be9a121486995202ba3fca74fbed93a407d6d54d43a43f96fd28d0bd7a06761591a
- languageName: node
- linkType: hard
-
-"cookie@npm:0.5.0":
- version: 0.5.0
- resolution: "cookie@npm:0.5.0"
- checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180
- languageName: node
- linkType: hard
-
-"cross-spawn-windows-exe@npm:^1.1.0, cross-spawn-windows-exe@npm:^1.2.0":
- version: 1.2.0
- resolution: "cross-spawn-windows-exe@npm:1.2.0"
- dependencies:
- "@malept/cross-spawn-promise": ^1.1.0
- is-wsl: ^2.2.0
- which: ^2.0.2
- checksum: 57662e8fb24b53f39330aa405e5bbce874dc5cc61fcf212031def1c6fbb1aa62f5635dcacb942d6165e97460984c16b0a57ee223b4c8492f4b92147c77bc573f
- languageName: node
- linkType: hard
-
-"cross-spawn@npm:^6.0.0, cross-spawn@npm:^6.0.5":
- version: 6.0.5
- resolution: "cross-spawn@npm:6.0.5"
- dependencies:
- nice-try: ^1.0.4
- path-key: ^2.0.1
- semver: ^5.5.0
- shebang-command: ^1.2.0
- which: ^1.2.9
- checksum: f893bb0d96cd3d5751d04e67145bdddf25f99449531a72e82dcbbd42796bbc8268c1076c6b3ea51d4d455839902804b94bc45dfb37ecbb32ea8e54a6741c3ab9
- languageName: node
- linkType: hard
-
-"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1":
- version: 7.0.3
- resolution: "cross-spawn@npm:7.0.3"
- dependencies:
- path-key: ^3.1.0
- shebang-command: ^2.0.0
- which: ^2.0.1
- checksum: 671cc7c7288c3a8406f3c69a3ae2fc85555c04169e9d611def9a675635472614f1c0ed0ef80955d5b6d4e724f6ced67f0ad1bb006c2ea643488fcfef994d7f52
- languageName: node
- linkType: hard
-
-"cross-zip@npm:^4.0.0":
- version: 4.0.0
- resolution: "cross-zip@npm:4.0.0"
- checksum: 055291adb4b18e69f9883b54a3c38acbfd8d810190d16966242f9b1795c8bb682b03e3a8633839cee574b1ce83ed2eec8079e3ab72ada38c0bae8d89ab9a42c3
- languageName: node
- linkType: hard
-
-"debug@npm:2.6.9, debug@npm:^2.2.0":
- version: 2.6.9
- resolution: "debug@npm:2.6.9"
- dependencies:
- ms: 2.0.0
- checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6
- languageName: node
- linkType: hard
-
-"debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4":
- version: 4.3.4
- resolution: "debug@npm:4.3.4"
- dependencies:
- ms: 2.1.2
- peerDependenciesMeta:
- supports-color:
- optional: true
- checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708
- languageName: node
- linkType: hard
-
-"debug@npm:^3.1.0":
- version: 3.2.7
- resolution: "debug@npm:3.2.7"
- dependencies:
- ms: ^2.1.1
- checksum: b3d8c5940799914d30314b7c3304a43305fd0715581a919dacb8b3176d024a782062368405b47491516d2091d6462d4d11f2f4974a405048094f8bfebfa3071c
- languageName: node
- linkType: hard
-
-"decamelize@npm:^1.2.0":
- version: 1.2.0
- resolution: "decamelize@npm:1.2.0"
- checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa
- languageName: node
- linkType: hard
-
-"decompress-response@npm:^6.0.0":
- version: 6.0.0
- resolution: "decompress-response@npm:6.0.0"
- dependencies:
- mimic-response: ^3.1.0
- checksum: d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812
- languageName: node
- linkType: hard
-
-"defaults@npm:^1.0.3":
- version: 1.0.4
- resolution: "defaults@npm:1.0.4"
- dependencies:
- clone: ^1.0.2
- checksum: 3a88b7a587fc076b84e60affad8b85245c01f60f38fc1d259e7ac1d89eb9ce6abb19e27215de46b98568dd5bc48471730b327637e6f20b0f1bc85cf00440c80a
- languageName: node
- linkType: hard
-
-"defer-to-connect@npm:^2.0.0":
- version: 2.0.1
- resolution: "defer-to-connect@npm:2.0.1"
- checksum: 8a9b50d2f25446c0bfefb55a48e90afd58f85b21bcf78e9207cd7b804354f6409032a1705c2491686e202e64fc05f147aa5aa45f9aa82627563f045937f5791b
- languageName: node
- linkType: hard
-
-"define-properties@npm:^1.1.3":
- version: 1.2.0
- resolution: "define-properties@npm:1.2.0"
- dependencies:
- has-property-descriptors: ^1.0.0
- object-keys: ^1.1.1
- checksum: e60aee6a19b102df4e2b1f301816804e81ab48bb91f00d0d935f269bf4b3f79c88b39e4f89eaa132890d23267335fd1140dfcd8d5ccd61031a0a2c41a54e33a6
- languageName: node
- linkType: hard
-
-"delegates@npm:^1.0.0":
- version: 1.0.0
- resolution: "delegates@npm:1.0.0"
- checksum: a51744d9b53c164ba9c0492471a1a2ffa0b6727451bdc89e31627fdf4adda9d51277cfcbfb20f0a6f08ccb3c436f341df3e92631a3440226d93a8971724771fd
- languageName: node
- linkType: hard
-
-"depd@npm:2.0.0, depd@npm:^2.0.0":
- version: 2.0.0
- resolution: "depd@npm:2.0.0"
- checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a
- languageName: node
- linkType: hard
-
-"destroy@npm:1.2.0":
- version: 1.2.0
- resolution: "destroy@npm:1.2.0"
- checksum: 0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38
- languageName: node
- linkType: hard
-
-"detect-libc@npm:^2.0.1":
- version: 2.0.2
- resolution: "detect-libc@npm:2.0.2"
- checksum: 2b2cd3649b83d576f4be7cc37eb3b1815c79969c8b1a03a40a4d55d83bc74d010753485753448eacb98784abf22f7dbd3911fd3b60e29fda28fed2d1a997944d
- languageName: node
- linkType: hard
-
-"detect-node@npm:^2.0.4":
- version: 2.1.0
- resolution: "detect-node@npm:2.1.0"
- checksum: 832184ec458353e41533ac9c622f16c19f7c02d8b10c303dfd3a756f56be93e903616c0bb2d4226183c9351c15fc0b3dba41a17a2308262afabcfa3776e6ae6e
- languageName: node
- linkType: hard
-
-"dir-compare@npm:^3.0.0":
- version: 3.3.0
- resolution: "dir-compare@npm:3.3.0"
- dependencies:
- buffer-equal: ^1.0.0
- minimatch: ^3.0.4
- checksum: 05e7381509b17cb4e6791bd9569c12ce4267f44b1ee36594946ed895ed7ad24da9285130dc42af3a60707d58c76307bb3a1cbae2acd0a9cce8c74664e6a26828
- languageName: node
- linkType: hard
-
-"ds-store@npm:^0.1.5":
- version: 0.1.6
- resolution: "ds-store@npm:0.1.6"
- dependencies:
- bplist-creator: ~0.0.3
- macos-alias: ~0.2.5
- tn1150: ^0.1.0
- checksum: b574fdd92d8008e6e089ca958a9d186e4cca2b69131004ccc958a06fcea0a1079b6efd0693a74ad7f85b1f5df69edbfb81896eaef1644e1d23c506f9740c0945
- languageName: node
- linkType: hard
-
-"eastasianwidth@npm:^0.2.0":
- version: 0.2.0
- resolution: "eastasianwidth@npm:0.2.0"
- checksum: 7d00d7cd8e49b9afa762a813faac332dee781932d6f2c848dc348939c4253f1d4564341b7af1d041853bc3f32c2ef141b58e0a4d9862c17a7f08f68df1e0f1ed
- languageName: node
- linkType: hard
-
-"ee-first@npm:1.1.1":
- version: 1.1.1
- resolution: "ee-first@npm:1.1.1"
- checksum: 1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f
- languageName: node
- linkType: hard
-
-"electron-installer-common@npm:^0.10.2":
- version: 0.10.3
- resolution: "electron-installer-common@npm:0.10.3"
- dependencies:
- "@malept/cross-spawn-promise": ^1.0.0
- "@types/fs-extra": ^9.0.1
- asar: ^3.0.0
- debug: ^4.1.1
- fs-extra: ^9.0.0
- glob: ^7.1.4
- lodash: ^4.17.15
- parse-author: ^2.0.0
- semver: ^7.1.1
- tmp-promise: ^3.0.2
- dependenciesMeta:
- "@types/fs-extra":
- optional: true
- checksum: c441c1fc1e8d57428b872cccf82e9748183588224fcdaf90189fa38f735311c7e4ebeb4c02d6b9e9901a3922f89b9c426e8be543359b44e2d12251be026f1ded
- languageName: node
- linkType: hard
-
-"electron-installer-debian@npm:^3.0.0":
- version: 3.1.0
- resolution: "electron-installer-debian@npm:3.1.0"
- dependencies:
- "@malept/cross-spawn-promise": ^1.0.0
- debug: ^4.1.1
- electron-installer-common: ^0.10.2
- fs-extra: ^9.0.0
- get-folder-size: ^2.0.1
- lodash: ^4.17.4
- word-wrap: ^1.2.3
- yargs: ^15.0.1
- bin:
- electron-installer-debian: src/cli.js
- conditions: (os=darwin | os=linux)
- languageName: node
- linkType: hard
-
-"electron-installer-dmg@npm:^4.0.0":
- version: 4.0.0
- resolution: "electron-installer-dmg@npm:4.0.0"
- dependencies:
- appdmg: ^0.6.4
- debug: ^4.3.2
- minimist: ^1.1.1
- dependenciesMeta:
- appdmg:
- optional: true
- bin:
- electron-installer-dmg: bin/electron-installer-dmg.js
- checksum: 59006b5a560bf08096d970a44b429c218cb3b0c99144d8f276a354af66312c6cb215b177e4411a833013754a0033c28b2c2dadf5cd2b1dfee7c8b6b6dbdc9dae
- languageName: node
- linkType: hard
-
-"electron-installer-redhat@npm:^3.2.0":
- version: 3.4.0
- resolution: "electron-installer-redhat@npm:3.4.0"
- dependencies:
- "@malept/cross-spawn-promise": ^1.0.0
- debug: ^4.1.1
- electron-installer-common: ^0.10.2
- fs-extra: ^9.0.0
- lodash: ^4.17.15
- word-wrap: ^1.2.3
- yargs: ^16.0.2
- bin:
- electron-installer-redhat: src/cli.js
- conditions: (os=darwin | os=linux)
- languageName: node
- linkType: hard
-
-"electron-packager@npm:^17.1.1":
- version: 17.1.1
- resolution: "electron-packager@npm:17.1.1"
- dependencies:
- "@electron/asar": ^3.2.1
- "@electron/get": ^2.0.0
- "@electron/notarize": ^1.2.3
- "@electron/osx-sign": ^1.0.1
- "@electron/universal": ^1.3.2
- cross-spawn-windows-exe: ^1.2.0
- debug: ^4.0.1
- extract-zip: ^2.0.0
- filenamify: ^4.1.0
- fs-extra: ^10.1.0
- galactus: ^0.2.1
- get-package-info: ^1.0.0
- junk: ^3.1.0
- parse-author: ^2.0.0
- plist: ^3.0.0
- rcedit: ^3.0.1
- resolve: ^1.1.6
- semver: ^7.1.3
- yargs-parser: ^21.1.1
- bin:
- electron-packager: bin/electron-packager.js
- checksum: db59ef057c47e1e2bb4b3c701a767aedef80893472d78e33ab73dd7dcf8bb77f6d5c80fe8d6f8afcd5a36bb5efe6a05f8fc425acb366f7871ad362cd6aefd9d5
- languageName: node
- linkType: hard
-
-"electron-squirrel-startup@npm:1.0.0":
- version: 1.0.0
- resolution: "electron-squirrel-startup@npm:1.0.0"
- dependencies:
- debug: ^2.2.0
- checksum: a1f658e326bd0f5c24aec95fd9a94a2e2b8b645adbd421465829f32719d15e85d6469d9369914c3b766d61e71eebb9f6725057b7fafa78adbcc5d6d3ce5d7a22
- languageName: node
- linkType: hard
-
-"electron-winstaller@npm:^5.0.0":
- version: 5.1.0
- resolution: "electron-winstaller@npm:5.1.0"
- dependencies:
- "@electron/asar": ^3.2.1
- debug: ^4.1.1
- fs-extra: ^7.0.1
- lodash.template: ^4.2.2
- temp: ^0.9.0
- checksum: a283b1ee0b0355a54602c807dcf55e7cef92b79ddd08de8ec1e0913ca0c976ed0c03ec651fb0cc69ff86d6a21f2caef7d6992b83c03af772cc03ddf17fd68151
- languageName: node
- linkType: hard
-
-"electron@npm:25.3.0":
- version: 25.3.0
- resolution: "electron@npm:25.3.0"
- dependencies:
- "@electron/get": ^2.0.0
- "@types/node": ^18.11.18
- extract-zip: ^2.0.1
- bin:
- electron: cli.js
- checksum: 60817fe35c71dd1c3a764b0f8eb99fbbd7a0ba2dde1f715d5ebdc75b27eefba7f98e2e3ba79c90f43f0c37931c9a4e78b9f1bc72e1a28772dfbe2cd85edc79bb
- languageName: node
- linkType: hard
-
-"emoji-regex@npm:^8.0.0":
- version: 8.0.0
- resolution: "emoji-regex@npm:8.0.0"
- checksum: d4c5c39d5a9868b5fa152f00cada8a936868fd3367f33f71be515ecee4c803132d11b31a6222b2571b1e5f7e13890156a94880345594d0ce7e3c9895f560f192
- languageName: node
- linkType: hard
-
-"emoji-regex@npm:^9.2.2":
- version: 9.2.2
- resolution: "emoji-regex@npm:9.2.2"
- checksum: 8487182da74aabd810ac6d6f1994111dfc0e331b01271ae01ec1eb0ad7b5ecc2bbbbd2f053c05cb55a1ac30449527d819bbfbf0e3de1023db308cbcb47f86601
- languageName: node
- linkType: hard
-
-"encode-utf8@npm:^1.0.3":
- version: 1.0.3
- resolution: "encode-utf8@npm:1.0.3"
- checksum: 550224bf2a104b1d355458c8a82e9b4ea07f9fc78387bc3a49c151b940ad26473de8dc9e121eefc4e84561cb0b46de1e4cd2bc766f72ee145e9ea9541482817f
- languageName: node
- linkType: hard
-
-"encodeurl@npm:~1.0.2":
- version: 1.0.2
- resolution: "encodeurl@npm:1.0.2"
- checksum: e50e3d508cdd9c4565ba72d2012e65038e5d71bdc9198cb125beb6237b5b1ade6c0d343998da9e170fb2eae52c1bed37d4d6d98a46ea423a0cddbed5ac3f780c
- languageName: node
- linkType: hard
-
-"encoding@npm:^0.1.13":
- version: 0.1.13
- resolution: "encoding@npm:0.1.13"
- dependencies:
- iconv-lite: ^0.6.2
- checksum: bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f
- languageName: node
- linkType: hard
-
-"end-of-stream@npm:^1.1.0":
- version: 1.4.4
- resolution: "end-of-stream@npm:1.4.4"
- dependencies:
- once: ^1.4.0
- checksum: 530a5a5a1e517e962854a31693dbb5c0b2fc40b46dad2a56a2deec656ca040631124f4795823acc68238147805f8b021abbe221f4afed5ef3c8e8efc2024908b
- languageName: node
- linkType: hard
-
-"env-paths@npm:^2.2.0":
- version: 2.2.1
- resolution: "env-paths@npm:2.2.1"
- checksum: 65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e
- languageName: node
- linkType: hard
-
-"err-code@npm:^2.0.2":
- version: 2.0.3
- resolution: "err-code@npm:2.0.3"
- checksum: 8b7b1be20d2de12d2255c0bc2ca638b7af5171142693299416e6a9339bd7d88fc8d7707d913d78e0993176005405a236b066b45666b27b797252c771156ace54
- languageName: node
- linkType: hard
-
-"error-ex@npm:^1.2.0":
- version: 1.3.2
- resolution: "error-ex@npm:1.3.2"
- dependencies:
- is-arrayish: ^0.2.1
- checksum: c1c2b8b65f9c91b0f9d75f0debaa7ec5b35c266c2cac5de412c1a6de86d4cbae04ae44e510378cb14d032d0645a36925d0186f8bb7367bcc629db256b743a001
- languageName: node
- linkType: hard
-
-"es6-error@npm:^4.1.1":
- version: 4.1.1
- resolution: "es6-error@npm:4.1.1"
- checksum: ae41332a51ec1323da6bbc5d75b7803ccdeddfae17c41b6166ebbafc8e8beb7a7b80b884b7fab1cc80df485860ac3c59d78605e860bb4f8cd816b3d6ade0d010
- languageName: node
- linkType: hard
-
-"esbuild@npm:^0.18.10":
- version: 0.18.14
- resolution: "esbuild@npm:0.18.14"
- dependencies:
- "@esbuild/android-arm": 0.18.14
- "@esbuild/android-arm64": 0.18.14
- "@esbuild/android-x64": 0.18.14
- "@esbuild/darwin-arm64": 0.18.14
- "@esbuild/darwin-x64": 0.18.14
- "@esbuild/freebsd-arm64": 0.18.14
- "@esbuild/freebsd-x64": 0.18.14
- "@esbuild/linux-arm": 0.18.14
- "@esbuild/linux-arm64": 0.18.14
- "@esbuild/linux-ia32": 0.18.14
- "@esbuild/linux-loong64": 0.18.14
- "@esbuild/linux-mips64el": 0.18.14
- "@esbuild/linux-ppc64": 0.18.14
- "@esbuild/linux-riscv64": 0.18.14
- "@esbuild/linux-s390x": 0.18.14
- "@esbuild/linux-x64": 0.18.14
- "@esbuild/netbsd-x64": 0.18.14
- "@esbuild/openbsd-x64": 0.18.14
- "@esbuild/sunos-x64": 0.18.14
- "@esbuild/win32-arm64": 0.18.14
- "@esbuild/win32-ia32": 0.18.14
- "@esbuild/win32-x64": 0.18.14
- dependenciesMeta:
- "@esbuild/android-arm":
- optional: true
- "@esbuild/android-arm64":
- optional: true
- "@esbuild/android-x64":
- optional: true
- "@esbuild/darwin-arm64":
- optional: true
- "@esbuild/darwin-x64":
- optional: true
- "@esbuild/freebsd-arm64":
- optional: true
- "@esbuild/freebsd-x64":
- optional: true
- "@esbuild/linux-arm":
- optional: true
- "@esbuild/linux-arm64":
- optional: true
- "@esbuild/linux-ia32":
- optional: true
- "@esbuild/linux-loong64":
- optional: true
- "@esbuild/linux-mips64el":
- optional: true
- "@esbuild/linux-ppc64":
- optional: true
- "@esbuild/linux-riscv64":
- optional: true
- "@esbuild/linux-s390x":
- optional: true
- "@esbuild/linux-x64":
- optional: true
- "@esbuild/netbsd-x64":
- optional: true
- "@esbuild/openbsd-x64":
- optional: true
- "@esbuild/sunos-x64":
- optional: true
- "@esbuild/win32-arm64":
- optional: true
- "@esbuild/win32-ia32":
- optional: true
- "@esbuild/win32-x64":
- optional: true
- bin:
- esbuild: bin/esbuild
- checksum: 1e07d4c269262a9c31f8c23e6d8d891e3ad3b62851b6c35651088d8e19a1be3f49fd09580be3154ba8253da1646f50099e78435dad4e38a14527721038785f77
- languageName: node
- linkType: hard
-
-"escalade@npm:^3.1.1":
- version: 3.1.1
- resolution: "escalade@npm:3.1.1"
- checksum: a3e2a99f07acb74b3ad4989c48ca0c3140f69f923e56d0cba0526240ee470b91010f9d39001f2a4a313841d237ede70a729e92125191ba5d21e74b106800b133
- languageName: node
- linkType: hard
-
-"escape-html@npm:~1.0.3":
- version: 1.0.3
- resolution: "escape-html@npm:1.0.3"
- checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24
- languageName: node
- linkType: hard
-
-"escape-string-regexp@npm:^1.0.2":
- version: 1.0.5
- resolution: "escape-string-regexp@npm:1.0.5"
- checksum: 6092fda75c63b110c706b6a9bfde8a612ad595b628f0bd2147eea1d3406723020810e591effc7db1da91d80a71a737a313567c5abb3813e8d9c71f4aa595b410
- languageName: node
- linkType: hard
-
-"escape-string-regexp@npm:^4.0.0":
- version: 4.0.0
- resolution: "escape-string-regexp@npm:4.0.0"
- checksum: 98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5
- languageName: node
- linkType: hard
-
-"etag@npm:~1.8.1":
- version: 1.8.1
- resolution: "etag@npm:1.8.1"
- checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff
- languageName: node
- linkType: hard
-
-"execa@npm:^1.0.0":
- version: 1.0.0
- resolution: "execa@npm:1.0.0"
- dependencies:
- cross-spawn: ^6.0.0
- get-stream: ^4.0.0
- is-stream: ^1.1.0
- npm-run-path: ^2.0.0
- p-finally: ^1.0.0
- signal-exit: ^3.0.0
- strip-eof: ^1.0.0
- checksum: ddf1342c1c7d02dd93b41364cd847640f6163350d9439071abf70bf4ceb1b9b2b2e37f54babb1d8dc1df8e0d8def32d0e81e74a2e62c3e1d70c303eb4c306bc4
- languageName: node
- linkType: hard
-
-"expand-tilde@npm:^2.0.0, expand-tilde@npm:^2.0.2":
- version: 2.0.2
- resolution: "expand-tilde@npm:2.0.2"
- dependencies:
- homedir-polyfill: ^1.0.1
- checksum: 2efe6ed407d229981b1b6ceb552438fbc9e5c7d6a6751ad6ced3e0aa5cf12f0b299da695e90d6c2ac79191b5c53c613e508f7149e4573abfbb540698ddb7301a
- languageName: node
- linkType: hard
-
-"exponential-backoff@npm:^3.1.1":
- version: 3.1.1
- resolution: "exponential-backoff@npm:3.1.1"
- checksum: 3d21519a4f8207c99f7457287291316306255a328770d320b401114ec8481986e4e467e854cb9914dd965e0a1ca810a23ccb559c642c88f4c7f55c55778a9b48
- languageName: node
- linkType: hard
-
-"express-ws@npm:^5.0.2":
- version: 5.0.2
- resolution: "express-ws@npm:5.0.2"
- dependencies:
- ws: ^7.4.6
- peerDependencies:
- express: ^4.0.0 || ^5.0.0-alpha.1
- checksum: a7134c51b6a630a369bbc7e06b6fad9ec174d535dd76c990ea6285e6cb08abad408ddb1162ba347ec5725fc483ae9f035f2eecb22ea91f3ecebff05772f62f0b
- languageName: node
- linkType: hard
-
-"express@npm:^4.17.1":
- version: 4.18.2
- resolution: "express@npm:4.18.2"
- dependencies:
- accepts: ~1.3.8
- array-flatten: 1.1.1
- body-parser: 1.20.1
- content-disposition: 0.5.4
- content-type: ~1.0.4
- cookie: 0.5.0
- cookie-signature: 1.0.6
- debug: 2.6.9
- depd: 2.0.0
- encodeurl: ~1.0.2
- escape-html: ~1.0.3
- etag: ~1.8.1
- finalhandler: 1.2.0
- fresh: 0.5.2
- http-errors: 2.0.0
- merge-descriptors: 1.0.1
- methods: ~1.1.2
- on-finished: 2.4.1
- parseurl: ~1.3.3
- path-to-regexp: 0.1.7
- proxy-addr: ~2.0.7
- qs: 6.11.0
- range-parser: ~1.2.1
- safe-buffer: 5.2.1
- send: 0.18.0
- serve-static: 1.15.0
- setprototypeof: 1.2.0
- statuses: 2.0.1
- type-is: ~1.6.18
- utils-merge: 1.0.1
- vary: ~1.1.2
- checksum: 3c4b9b076879442f6b968fe53d85d9f1eeacbb4f4c41e5f16cc36d77ce39a2b0d81b3f250514982110d815b2f7173f5561367f9110fcc541f9371948e8c8b037
- languageName: node
- linkType: hard
-
-"extract-zip@npm:^2.0.0, extract-zip@npm:^2.0.1":
- version: 2.0.1
- resolution: "extract-zip@npm:2.0.1"
- dependencies:
- "@types/yauzl": ^2.9.1
- debug: ^4.1.1
- get-stream: ^5.1.0
- yauzl: ^2.10.0
- dependenciesMeta:
- "@types/yauzl":
- optional: true
- bin:
- extract-zip: cli.js
- checksum: 8cbda9debdd6d6980819cc69734d874ddd71051c9fe5bde1ef307ebcedfe949ba57b004894b585f758b7c9eeeea0e3d87f2dda89b7d25320459c2c9643ebb635
- languageName: node
- linkType: hard
-
-"fast-glob@npm:^3.2.7":
- version: 3.3.0
- resolution: "fast-glob@npm:3.3.0"
- dependencies:
- "@nodelib/fs.stat": ^2.0.2
- "@nodelib/fs.walk": ^1.2.3
- glob-parent: ^5.1.2
- merge2: ^1.3.0
- micromatch: ^4.0.4
- checksum: 20df62be28eb5426fe8e40e0d05601a63b1daceb7c3d87534afcad91bdcf1e4b1743cf2d5247d6e225b120b46df0b9053a032b2691ba34ee121e033acd81f547
- languageName: node
- linkType: hard
-
-"fastq@npm:^1.6.0":
- version: 1.15.0
- resolution: "fastq@npm:1.15.0"
- dependencies:
- reusify: ^1.0.4
- checksum: 0170e6bfcd5d57a70412440b8ef600da6de3b2a6c5966aeaf0a852d542daff506a0ee92d6de7679d1de82e644bce69d7a574a6c93f0b03964b5337eed75ada1a
- languageName: node
- linkType: hard
-
-"fd-slicer@npm:~1.1.0":
- version: 1.1.0
- resolution: "fd-slicer@npm:1.1.0"
- dependencies:
- pend: ~1.2.0
- checksum: c8585fd5713f4476eb8261150900d2cb7f6ff2d87f8feb306ccc8a1122efd152f1783bdb2b8dc891395744583436bfd8081d8e63ece0ec8687eeefea394d4ff2
- languageName: node
- linkType: hard
-
-"filename-reserved-regex@npm:^2.0.0":
- version: 2.0.0
- resolution: "filename-reserved-regex@npm:2.0.0"
- checksum: 323a0020fd7f243238ffccab9d728cbc5f3a13c84b2c10e01efb09b8324561d7a51776be76f36603c734d4f69145c39a5d12492bf6142a28b50d7f90bd6190bc
- languageName: node
- linkType: hard
-
-"filenamify@npm:^4.1.0":
- version: 4.3.0
- resolution: "filenamify@npm:4.3.0"
- dependencies:
- filename-reserved-regex: ^2.0.0
- strip-outer: ^1.0.1
- trim-repeated: ^1.0.0
- checksum: 5b71a7ff8e958c8621957e6fbf7872024126d3b5da50f59b1634af3343ba1a69d4cc15cfe4ca4bbfa7c959ad4d98614ee51e6f1d9fa7326eef8ceda2da8cd74e
- languageName: node
- linkType: hard
-
-"fill-range@npm:^7.0.1":
- version: 7.0.1
- resolution: "fill-range@npm:7.0.1"
- dependencies:
- to-regex-range: ^5.0.1
- checksum: cc283f4e65b504259e64fd969bcf4def4eb08d85565e906b7d36516e87819db52029a76b6363d0f02d0d532f0033c9603b9e2d943d56ee3b0d4f7ad3328ff917
- languageName: node
- linkType: hard
-
-"finalhandler@npm:1.2.0":
- version: 1.2.0
- resolution: "finalhandler@npm:1.2.0"
- dependencies:
- debug: 2.6.9
- encodeurl: ~1.0.2
- escape-html: ~1.0.3
- on-finished: 2.4.1
- parseurl: ~1.3.3
- statuses: 2.0.1
- unpipe: ~1.0.0
- checksum: 92effbfd32e22a7dff2994acedbd9bcc3aa646a3e919ea6a53238090e87097f8ef07cced90aa2cc421abdf993aefbdd5b00104d55c7c5479a8d00ed105b45716
- languageName: node
- linkType: hard
-
-"find-up@npm:^2.0.0":
- version: 2.1.0
- resolution: "find-up@npm:2.1.0"
- dependencies:
- locate-path: ^2.0.0
- checksum: 43284fe4da09f89011f08e3c32cd38401e786b19226ea440b75386c1b12a4cb738c94969808d53a84f564ede22f732c8409e3cfc3f7fb5b5c32378ad0bbf28bd
- languageName: node
- linkType: hard
-
-"find-up@npm:^4.0.0, find-up@npm:^4.1.0":
- version: 4.1.0
- resolution: "find-up@npm:4.1.0"
- dependencies:
- locate-path: ^5.0.0
- path-exists: ^4.0.0
- checksum: 4c172680e8f8c1f78839486e14a43ef82e9decd0e74145f40707cc42e7420506d5ec92d9a11c22bd2c48fb0c384ea05dd30e10dd152fefeec6f2f75282a8b844
- languageName: node
- linkType: hard
-
-"find-up@npm:^5.0.0":
- version: 5.0.0
- resolution: "find-up@npm:5.0.0"
- dependencies:
- locate-path: ^6.0.0
- path-exists: ^4.0.0
- checksum: 07955e357348f34660bde7920783204ff5a26ac2cafcaa28bace494027158a97b9f56faaf2d89a6106211a8174db650dd9f503f9c0d526b1202d5554a00b9095
- languageName: node
- linkType: hard
-
-"flora-colossus@npm:^1.0.0":
- version: 1.0.1
- resolution: "flora-colossus@npm:1.0.1"
- dependencies:
- debug: ^4.1.1
- fs-extra: ^7.0.0
- checksum: c3d0387aee84a4f95564c6eb0b38a5925226f8561c309ddea49984db5ae19eaa95f08b6b0005bcae062cceea01dcd837968341dc24855e0c3f53479a5ed6854c
- languageName: node
- linkType: hard
-
-"fmix@npm:^0.1.0":
- version: 0.1.0
- resolution: "fmix@npm:0.1.0"
- dependencies:
- imul: ^1.0.0
- checksum: c465344d4f169eaf10d45c33949a1e7a633f09dba2ac7063ce8ae8be743df5979d708f7f24900163589f047f5194ac5fc2476177ce31175e8805adfa7b8fb7a4
- languageName: node
- linkType: hard
-
-"foreground-child@npm:^3.1.0":
- version: 3.1.1
- resolution: "foreground-child@npm:3.1.1"
- dependencies:
- cross-spawn: ^7.0.0
- signal-exit: ^4.0.1
- checksum: 139d270bc82dc9e6f8bc045fe2aae4001dc2472157044fdfad376d0a3457f77857fa883c1c8b21b491c6caade9a926a4bed3d3d2e8d3c9202b151a4cbbd0bcd5
- languageName: node
- linkType: hard
-
-"forwarded@npm:0.2.0":
- version: 0.2.0
- resolution: "forwarded@npm:0.2.0"
- checksum: fd27e2394d8887ebd16a66ffc889dc983fbbd797d5d3f01087c020283c0f019a7d05ee85669383d8e0d216b116d720fc0cef2f6e9b7eb9f4c90c6e0bc7fd28e6
- languageName: node
- linkType: hard
-
-"fresh@npm:0.5.2":
- version: 0.5.2
- resolution: "fresh@npm:0.5.2"
- checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346
- languageName: node
- linkType: hard
-
-"fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0":
- version: 10.1.0
- resolution: "fs-extra@npm:10.1.0"
- dependencies:
- graceful-fs: ^4.2.0
- jsonfile: ^6.0.1
- universalify: ^2.0.0
- checksum: dc94ab37096f813cc3ca12f0f1b5ad6744dfed9ed21e953d72530d103cea193c2f81584a39e9dee1bea36de5ee66805678c0dddc048e8af1427ac19c00fffc50
- languageName: node
- linkType: hard
-
-"fs-extra@npm:^4.0.0":
- version: 4.0.3
- resolution: "fs-extra@npm:4.0.3"
- dependencies:
- graceful-fs: ^4.1.2
- jsonfile: ^4.0.0
- universalify: ^0.1.0
- checksum: c5ae3c7043ad7187128e619c0371da01b58694c1ffa02c36fb3f5b459925d9c27c3cb1e095d9df0a34a85ca993d8b8ff6f6ecef868fd5ebb243548afa7fc0936
- languageName: node
- linkType: hard
-
-"fs-extra@npm:^7.0.0, fs-extra@npm:^7.0.1":
- version: 7.0.1
- resolution: "fs-extra@npm:7.0.1"
- dependencies:
- graceful-fs: ^4.1.2
- jsonfile: ^4.0.0
- universalify: ^0.1.0
- checksum: 141b9dccb23b66a66cefdd81f4cda959ff89282b1d721b98cea19ba08db3dcbe6f862f28841f3cf24bb299e0b7e6c42303908f65093cb7e201708e86ea5a8dcf
- languageName: node
- linkType: hard
-
-"fs-extra@npm:^8.1.0":
- version: 8.1.0
- resolution: "fs-extra@npm:8.1.0"
- dependencies:
- graceful-fs: ^4.2.0
- jsonfile: ^4.0.0
- universalify: ^0.1.0
- checksum: bf44f0e6cea59d5ce071bba4c43ca76d216f89e402dc6285c128abc0902e9b8525135aa808adad72c9d5d218e9f4bcc63962815529ff2f684ad532172a284880
- languageName: node
- linkType: hard
-
-"fs-extra@npm:^9.0.0, fs-extra@npm:^9.0.1":
- version: 9.1.0
- resolution: "fs-extra@npm:9.1.0"
- dependencies:
- at-least-node: ^1.0.0
- graceful-fs: ^4.2.0
- jsonfile: ^6.0.1
- universalify: ^2.0.0
- checksum: ba71ba32e0faa74ab931b7a0031d1523c66a73e225de7426e275e238e312d07313d2da2d33e34a52aa406c8763ade5712eb3ec9ba4d9edce652bcacdc29e6b20
- languageName: node
- linkType: hard
-
-"fs-minipass@npm:^2.0.0":
- version: 2.1.0
- resolution: "fs-minipass@npm:2.1.0"
- dependencies:
- minipass: ^3.0.0
- checksum: 1b8d128dae2ac6cc94230cc5ead341ba3e0efaef82dab46a33d171c044caaa6ca001364178d42069b2809c35a1c3c35079a32107c770e9ffab3901b59af8c8b1
- languageName: node
- linkType: hard
-
-"fs-minipass@npm:^3.0.0":
- version: 3.0.2
- resolution: "fs-minipass@npm:3.0.2"
- dependencies:
- minipass: ^5.0.0
- checksum: e9cc0e1f2d01c6f6f62f567aee59530aba65c6c7b2ae88c5027bc34c711ebcfcfaefd0caf254afa6adfe7d1fba16bc2537508a6235196bac7276747d078aef0a
- languageName: node
- linkType: hard
-
-"fs-temp@npm:^1.0.0":
- version: 1.2.1
- resolution: "fs-temp@npm:1.2.1"
- dependencies:
- random-path: ^0.1.0
- checksum: 64d1b96c7adc172a0fbe6116f425f3588ac585dc7011524174e539df7794a4ca81874bb1c8ee74a47991cc35b7dc036f5bf880074844b2165027042b346b38d9
- languageName: node
- linkType: hard
-
-"fs-xattr@npm:^0.3.0":
- version: 0.3.1
- resolution: "fs-xattr@npm:0.3.1"
- dependencies:
- node-gyp: latest
- conditions: "!os=win32"
- languageName: node
- linkType: hard
-
-"fs.realpath@npm:^1.0.0":
- version: 1.0.0
- resolution: "fs.realpath@npm:1.0.0"
- checksum: 99ddea01a7e75aa276c250a04eedeffe5662bce66c65c07164ad6264f9de18fb21be9433ead460e54cff20e31721c811f4fb5d70591799df5f85dce6d6746fd0
- languageName: node
- linkType: hard
-
-"fsevents@npm:~2.3.2":
- version: 2.3.2
- resolution: "fsevents@npm:2.3.2"
- dependencies:
- node-gyp: latest
- checksum: 97ade64e75091afee5265e6956cb72ba34db7819b4c3e94c431d4be2b19b8bb7a2d4116da417950c3425f17c8fe693d25e20212cac583ac1521ad066b77ae31f
- conditions: os=darwin
- languageName: node
- linkType: hard
-
-"fsevents@patch:fsevents@~2.3.2#~builtin":
- version: 2.3.2
- resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=df0bf1"
- dependencies:
- node-gyp: latest
- conditions: os=darwin
- languageName: node
- linkType: hard
-
-"function-bind@npm:^1.1.1":
- version: 1.1.1
- resolution: "function-bind@npm:1.1.1"
- checksum: b32fbaebb3f8ec4969f033073b43f5c8befbb58f1a79e12f1d7490358150359ebd92f49e72ff0144f65f2c48ea2a605bff2d07965f548f6474fd8efd95bf361a
- languageName: node
- linkType: hard
-
-"galactus@npm:^0.2.1":
- version: 0.2.1
- resolution: "galactus@npm:0.2.1"
- dependencies:
- debug: ^3.1.0
- flora-colossus: ^1.0.0
- fs-extra: ^4.0.0
- checksum: c026c180ea7bd5a80c3e493a561e30973fcbc9b05dbf036b9143d8fbfdfac81d969159f319c3d7088217e59a8b74389aa1d55217062ffbd793dc952c85d2bc97
- languageName: node
- linkType: hard
-
-"gar@npm:^1.0.4":
- version: 1.0.4
- resolution: "gar@npm:1.0.4"
- checksum: 6b1010b5c17056526298734bfa08716f111cd023394dbe32496841e2f7b0dfe9e742b8ddb56103c0867f2ae80f5f069262916e5398ac982467be4da240ba7bb9
- languageName: node
- linkType: hard
-
-"gauge@npm:^4.0.3":
- version: 4.0.4
- resolution: "gauge@npm:4.0.4"
- dependencies:
- aproba: ^1.0.3 || ^2.0.0
- color-support: ^1.1.3
- console-control-strings: ^1.1.0
- has-unicode: ^2.0.1
- signal-exit: ^3.0.7
- string-width: ^4.2.3
- strip-ansi: ^6.0.1
- wide-align: ^1.1.5
- checksum: 788b6bfe52f1dd8e263cda800c26ac0ca2ff6de0b6eee2fe0d9e3abf15e149b651bd27bf5226be10e6e3edb5c4e5d5985a5a1a98137e7a892f75eff76467ad2d
- languageName: node
- linkType: hard
-
-"generate-function@npm:^2.0.0":
- version: 2.3.1
- resolution: "generate-function@npm:2.3.1"
- dependencies:
- is-property: ^1.0.2
- checksum: 652f083de206ead2bae4caf9c7eeb465e8d98c0b8ed2a29c6afc538cef0785b5c6eea10548f1e13cc586d3afd796c13c830c2cb3dc612ec2457b2aadda5f57c9
- languageName: node
- linkType: hard
-
-"generate-object-property@npm:^1.1.0":
- version: 1.2.0
- resolution: "generate-object-property@npm:1.2.0"
- dependencies:
- is-property: ^1.0.0
- checksum: 5141ca5fd545f0aabd24fd13f9f3ecf9cfea2255db00d46e282d65141d691d560c70b6361c3c0c4982f86f600361925bfd4773e0350c66d0210e6129ae553a09
- languageName: node
- linkType: hard
-
-"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5":
- version: 2.0.5
- resolution: "get-caller-file@npm:2.0.5"
- checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9
- languageName: node
- linkType: hard
-
-"get-folder-size@npm:^2.0.1":
- version: 2.0.1
- resolution: "get-folder-size@npm:2.0.1"
- dependencies:
- gar: ^1.0.4
- tiny-each-async: 2.0.3
- bin:
- get-folder-size: bin/get-folder-size
- checksum: f6bc0fe8dda84aa15ca2170ffbeefde99870e6f6cfc807bd6eb035163b53c3266e41be66ea34b181a296a535dd976d7f26eff2bbaf6d1d6e8833d6634032549a
- languageName: node
- linkType: hard
-
-"get-installed-path@npm:^2.0.3":
- version: 2.1.1
- resolution: "get-installed-path@npm:2.1.1"
- dependencies:
- global-modules: 1.0.0
- checksum: 7b07d8279a5e3629378ddf4d310653dfa478b74ace43b90e93954455085231946e6f97e7870a5b92d4fa3e45b423b8aebcae652dee742b01a797f54f1c1e90a9
- languageName: node
- linkType: hard
-
-"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1":
- version: 1.2.1
- resolution: "get-intrinsic@npm:1.2.1"
- dependencies:
- function-bind: ^1.1.1
- has: ^1.0.3
- has-proto: ^1.0.1
- has-symbols: ^1.0.3
- checksum: 5b61d88552c24b0cf6fa2d1b3bc5459d7306f699de060d76442cce49a4721f52b8c560a33ab392cf5575b7810277d54ded9d4d39a1ea61855619ebc005aa7e5f
- languageName: node
- linkType: hard
-
-"get-package-info@npm:^1.0.0":
- version: 1.0.0
- resolution: "get-package-info@npm:1.0.0"
- dependencies:
- bluebird: ^3.1.1
- debug: ^2.2.0
- lodash.get: ^4.0.0
- read-pkg-up: ^2.0.0
- checksum: 6b2c99d9eaf7adbd7fa246fdcf1b20fc5171d2be661e042dc1bf851cdb028955640745c88f2f92463477cba9030240fad05619ddc874bc99f9c021921e892462
- languageName: node
- linkType: hard
-
-"get-stream@npm:^4.0.0":
- version: 4.1.0
- resolution: "get-stream@npm:4.1.0"
- dependencies:
- pump: ^3.0.0
- checksum: 443e1914170c15bd52ff8ea6eff6dfc6d712b031303e36302d2778e3de2506af9ee964d6124010f7818736dcfde05c04ba7ca6cc26883106e084357a17ae7d73
- languageName: node
- linkType: hard
-
-"get-stream@npm:^5.1.0":
- version: 5.2.0
- resolution: "get-stream@npm:5.2.0"
- dependencies:
- pump: ^3.0.0
- checksum: 8bc1a23174a06b2b4ce600df38d6c98d2ef6d84e020c1ddad632ad75bac4e092eeb40e4c09e0761c35fc2dbc5e7fff5dab5e763a383582c4a167dd69a905bd12
- languageName: node
- linkType: hard
-
-"glob-parent@npm:^5.1.2":
- version: 5.1.2
- resolution: "glob-parent@npm:5.1.2"
- dependencies:
- is-glob: ^4.0.1
- checksum: f4f2bfe2425296e8a47e36864e4f42be38a996db40420fe434565e4480e3322f18eb37589617a98640c5dc8fdec1a387007ee18dbb1f3f5553409c34d17f425e
- languageName: node
- linkType: hard
-
-"glob@npm:^10.2.2":
- version: 10.3.3
- resolution: "glob@npm:10.3.3"
- dependencies:
- foreground-child: ^3.1.0
- jackspeak: ^2.0.3
- minimatch: ^9.0.1
- minipass: ^5.0.0 || ^6.0.2 || ^7.0.0
- path-scurry: ^1.10.1
- bin:
- glob: dist/cjs/src/bin.js
- checksum: 29190d3291f422da0cb40b77a72fc8d2c51a36524e99b8bf412548b7676a6627489528b57250429612b6eec2e6fe7826d328451d3e694a9d15e575389308ec53
- languageName: node
- linkType: hard
-
-"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6":
- version: 7.2.3
- resolution: "glob@npm:7.2.3"
- dependencies:
- fs.realpath: ^1.0.0
- inflight: ^1.0.4
- inherits: 2
- minimatch: ^3.1.1
- once: ^1.3.0
- path-is-absolute: ^1.0.0
- checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133
- languageName: node
- linkType: hard
-
-"global-agent@npm:^3.0.0":
- version: 3.0.0
- resolution: "global-agent@npm:3.0.0"
- dependencies:
- boolean: ^3.0.1
- es6-error: ^4.1.1
- matcher: ^3.0.0
- roarr: ^2.15.3
- semver: ^7.3.2
- serialize-error: ^7.0.1
- checksum: 75074d80733b4bd5386c47f5df028e798018025beac0ab310e9908c72bf5639e408203e7bca0130d5ee01b5f4abc6d34385d96a9f950ea5fe1979bb431c808f7
- languageName: node
- linkType: hard
-
-"global-modules@npm:1.0.0, global-modules@npm:^1.0.0":
- version: 1.0.0
- resolution: "global-modules@npm:1.0.0"
- dependencies:
- global-prefix: ^1.0.1
- is-windows: ^1.0.1
- resolve-dir: ^1.0.0
- checksum: 10be68796c1e1abc1e2ba87ec4ea507f5629873b119ab0cd29c07284ef2b930f1402d10df01beccb7391dedd9cd479611dd6a24311c71be58937beaf18edf85e
- languageName: node
- linkType: hard
-
-"global-prefix@npm:^1.0.1":
- version: 1.0.2
- resolution: "global-prefix@npm:1.0.2"
- dependencies:
- expand-tilde: ^2.0.2
- homedir-polyfill: ^1.0.1
- ini: ^1.3.4
- is-windows: ^1.0.1
- which: ^1.2.14
- checksum: 061b43470fe498271bcd514e7746e8a8535032b17ab9570517014ae27d700ff0dca749f76bbde13ba384d185be4310d8ba5712cb0e74f7d54d59390db63dd9a0
- languageName: node
- linkType: hard
-
-"globalthis@npm:^1.0.1":
- version: 1.0.3
- resolution: "globalthis@npm:1.0.3"
- dependencies:
- define-properties: ^1.1.3
- checksum: fbd7d760dc464c886d0196166d92e5ffb4c84d0730846d6621a39fbbc068aeeb9c8d1421ad330e94b7bca4bb4ea092f5f21f3d36077812af5d098b4dc006c998
- languageName: node
- linkType: hard
-
-"got@npm:^11.7.0, got@npm:^11.8.5":
- version: 11.8.6
- resolution: "got@npm:11.8.6"
- dependencies:
- "@sindresorhus/is": ^4.0.0
- "@szmarczak/http-timer": ^4.0.5
- "@types/cacheable-request": ^6.0.1
- "@types/responselike": ^1.0.0
- cacheable-lookup: ^5.0.3
- cacheable-request: ^7.0.2
- decompress-response: ^6.0.0
- http2-wrapper: ^1.0.0-beta.5.2
- lowercase-keys: ^2.0.0
- p-cancelable: ^2.0.0
- responselike: ^2.0.0
- checksum: bbc783578a8d5030c8164ef7f57ce41b5ad7db2ed13371e1944bef157eeca5a7475530e07c0aaa71610d7085474d0d96222c9f4268d41db333a17e39b463f45d
- languageName: node
- linkType: hard
-
-"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6":
- version: 4.2.11
- resolution: "graceful-fs@npm:4.2.11"
- checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7
- languageName: node
- linkType: hard
-
-"has-flag@npm:^4.0.0":
- version: 4.0.0
- resolution: "has-flag@npm:4.0.0"
- checksum: 261a1357037ead75e338156b1f9452c016a37dcd3283a972a30d9e4a87441ba372c8b81f818cd0fbcd9c0354b4ae7e18b9e1afa1971164aef6d18c2b6095a8ad
- languageName: node
- linkType: hard
-
-"has-property-descriptors@npm:^1.0.0":
- version: 1.0.0
- resolution: "has-property-descriptors@npm:1.0.0"
- dependencies:
- get-intrinsic: ^1.1.1
- checksum: a6d3f0a266d0294d972e354782e872e2fe1b6495b321e6ef678c9b7a06a40408a6891817350c62e752adced73a94ac903c54734fee05bf65b1905ee1368194bb
- languageName: node
- linkType: hard
-
-"has-proto@npm:^1.0.1":
- version: 1.0.1
- resolution: "has-proto@npm:1.0.1"
- checksum: febc5b5b531de8022806ad7407935e2135f1cc9e64636c3916c6842bd7995994ca3b29871ecd7954bd35f9e2986c17b3b227880484d22259e2f8e6ce63fd383e
- languageName: node
- linkType: hard
-
-"has-symbols@npm:^1.0.3":
- version: 1.0.3
- resolution: "has-symbols@npm:1.0.3"
- checksum: a054c40c631c0d5741a8285010a0777ea0c068f99ed43e5d6eb12972da223f8af553a455132fdb0801bdcfa0e0f443c0c03a68d8555aa529b3144b446c3f2410
- languageName: node
- linkType: hard
-
-"has-unicode@npm:^2.0.1":
- version: 2.0.1
- resolution: "has-unicode@npm:2.0.1"
- checksum: 1eab07a7436512db0be40a710b29b5dc21fa04880b7f63c9980b706683127e3c1b57cb80ea96d47991bdae2dfe479604f6a1ba410106ee1046a41d1bd0814400
- languageName: node
- linkType: hard
-
-"has@npm:^1.0.3":
- version: 1.0.3
- resolution: "has@npm:1.0.3"
- dependencies:
- function-bind: ^1.1.1
- checksum: b9ad53d53be4af90ce5d1c38331e712522417d017d5ef1ebd0507e07c2fbad8686fffb8e12ddecd4c39ca9b9b47431afbb975b8abf7f3c3b82c98e9aad052792
- languageName: node
- linkType: hard
-
-"homedir-polyfill@npm:^1.0.1":
- version: 1.0.3
- resolution: "homedir-polyfill@npm:1.0.3"
- dependencies:
- parse-passwd: ^1.0.0
- checksum: 18dd4db87052c6a2179d1813adea0c4bfcfa4f9996f0e226fefb29eb3d548e564350fa28ec46b0bf1fbc0a1d2d6922ceceb80093115ea45ff8842a4990139250
- languageName: node
- linkType: hard
-
-"hosted-git-info@npm:^2.1.4":
- version: 2.8.9
- resolution: "hosted-git-info@npm:2.8.9"
- checksum: c955394bdab888a1e9bb10eb33029e0f7ce5a2ac7b3f158099dc8c486c99e73809dca609f5694b223920ca2174db33d32b12f9a2a47141dc59607c29da5a62dd
- languageName: node
- linkType: hard
-
-"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1":
- version: 4.1.1
- resolution: "http-cache-semantics@npm:4.1.1"
- checksum: 83ac0bc60b17a3a36f9953e7be55e5c8f41acc61b22583060e8dedc9dd5e3607c823a88d0926f9150e571f90946835c7fe150732801010845c72cd8bbff1a236
- languageName: node
- linkType: hard
-
-"http-errors@npm:2.0.0":
- version: 2.0.0
- resolution: "http-errors@npm:2.0.0"
- dependencies:
- depd: 2.0.0
- inherits: 2.0.4
- setprototypeof: 1.2.0
- statuses: 2.0.1
- toidentifier: 1.0.1
- checksum: 9b0a3782665c52ce9dc658a0d1560bcb0214ba5699e4ea15aefb2a496e2ca83db03ebc42e1cce4ac1f413e4e0d2d736a3fd755772c556a9a06853ba2a0b7d920
- languageName: node
- linkType: hard
-
-"http-proxy-agent@npm:^5.0.0":
- version: 5.0.0
- resolution: "http-proxy-agent@npm:5.0.0"
- dependencies:
- "@tootallnate/once": 2
- agent-base: 6
- debug: 4
- checksum: e2ee1ff1656a131953839b2a19cd1f3a52d97c25ba87bd2559af6ae87114abf60971e498021f9b73f9fd78aea8876d1fb0d4656aac8a03c6caa9fc175f22b786
- languageName: node
- linkType: hard
-
-"http2-wrapper@npm:^1.0.0-beta.5.2":
- version: 1.0.3
- resolution: "http2-wrapper@npm:1.0.3"
- dependencies:
- quick-lru: ^5.1.1
- resolve-alpn: ^1.0.0
- checksum: 74160b862ec699e3f859739101ff592d52ce1cb207b7950295bf7962e4aa1597ef709b4292c673bece9c9b300efad0559fc86c71b1409c7a1e02b7229456003e
- languageName: node
- linkType: hard
-
-"https-proxy-agent@npm:^5.0.0":
- version: 5.0.1
- resolution: "https-proxy-agent@npm:5.0.1"
- dependencies:
- agent-base: 6
- debug: 4
- checksum: 571fccdf38184f05943e12d37d6ce38197becdd69e58d03f43637f7fa1269cf303a7d228aa27e5b27bbd3af8f09fd938e1c91dcfefff2df7ba77c20ed8dfc765
- languageName: node
- linkType: hard
-
-"humanize-ms@npm:^1.2.1":
- version: 1.2.1
- resolution: "humanize-ms@npm:1.2.1"
- dependencies:
- ms: ^2.0.0
- checksum: 9c7a74a2827f9294c009266c82031030eae811ca87b0da3dceb8d6071b9bde22c9f3daef0469c3c533cc67a97d8a167cd9fc0389350e5f415f61a79b171ded16
- languageName: node
- linkType: hard
-
-"iconv-lite@npm:0.4.24":
- version: 0.4.24
- resolution: "iconv-lite@npm:0.4.24"
- dependencies:
- safer-buffer: ">= 2.1.2 < 3"
- checksum: bd9f120f5a5b306f0bc0b9ae1edeb1577161503f5f8252a20f1a9e56ef8775c9959fd01c55f2d3a39d9a8abaf3e30c1abeb1895f367dcbbe0a8fd1c9ca01c4f6
- languageName: node
- linkType: hard
-
-"iconv-lite@npm:^0.6.2":
- version: 0.6.3
- resolution: "iconv-lite@npm:0.6.3"
- dependencies:
- safer-buffer: ">= 2.1.2 < 3.0.0"
- checksum: 3f60d47a5c8fc3313317edfd29a00a692cc87a19cac0159e2ce711d0ebc9019064108323b5e493625e25594f11c6236647d8e256fbe7a58f4a3b33b89e6d30bf
- languageName: node
- linkType: hard
-
-"ieee754@npm:^1.1.13":
- version: 1.2.1
- resolution: "ieee754@npm:1.2.1"
- checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
- languageName: node
- linkType: hard
-
-"image-size@npm:^0.7.4":
- version: 0.7.5
- resolution: "image-size@npm:0.7.5"
- bin:
- image-size: bin/image-size.js
- checksum: f88860c9d9b5c8ad00d3de9d6f5ba105bda5a5024bfb6b90559a075a4b838ed4f5d3cba14edf0f18fe5d75df596a172b52feca43848e11c34f31f4df2c88a011
- languageName: node
- linkType: hard
-
-"imul@npm:^1.0.0":
- version: 1.0.1
- resolution: "imul@npm:1.0.1"
- checksum: 6c2af3d5f09e2135e14d565a2c108412b825b221eb2c881f9130467f2adccf7ae201773ae8bcf1be169e2d090567a1fdfa9cf20d3b7da7b9cecb95b920ff3e52
- languageName: node
- linkType: hard
-
-"imurmurhash@npm:^0.1.4":
- version: 0.1.4
- resolution: "imurmurhash@npm:0.1.4"
- checksum: 7cae75c8cd9a50f57dadd77482359f659eaebac0319dd9368bcd1714f55e65badd6929ca58569da2b6494ef13fdd5598cd700b1eba23f8b79c5f19d195a3ecf7
- languageName: node
- linkType: hard
-
-"indent-string@npm:^4.0.0":
- version: 4.0.0
- resolution: "indent-string@npm:4.0.0"
- checksum: 824cfb9929d031dabf059bebfe08cf3137365e112019086ed3dcff6a0a7b698cb80cf67ccccde0e25b9e2d7527aa6cc1fed1ac490c752162496caba3e6699612
- languageName: node
- linkType: hard
-
-"inflight@npm:^1.0.4":
- version: 1.0.6
- resolution: "inflight@npm:1.0.6"
- dependencies:
- once: ^1.3.0
- wrappy: 1
- checksum: f4f76aa072ce19fae87ce1ef7d221e709afb59d445e05d47fba710e85470923a75de35bfae47da6de1b18afc3ce83d70facf44cfb0aff89f0a3f45c0a0244dfd
- languageName: node
- linkType: hard
-
-"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4":
- version: 2.0.4
- resolution: "inherits@npm:2.0.4"
- checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1
- languageName: node
- linkType: hard
-
-"ini@npm:^1.3.4":
- version: 1.3.8
- resolution: "ini@npm:1.3.8"
- checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3
- languageName: node
- linkType: hard
-
-"interpret@npm:^3.1.1":
- version: 3.1.1
- resolution: "interpret@npm:3.1.1"
- checksum: 35cebcf48c7351130437596d9ab8c8fe131ce4038da4561e6d665f25640e0034702a031cf7e3a5cea60ac7ac548bf17465e0571ede126f3d3a6933152171ac82
- languageName: node
- linkType: hard
-
-"ip@npm:^2.0.0":
- version: 2.0.0
- resolution: "ip@npm:2.0.0"
- checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349
- languageName: node
- linkType: hard
-
-"ipaddr.js@npm:1.9.1":
- version: 1.9.1
- resolution: "ipaddr.js@npm:1.9.1"
- checksum: f88d3825981486f5a1942414c8d77dd6674dd71c065adcfa46f578d677edcb99fda25af42675cb59db492fdf427b34a5abfcde3982da11a8fd83a500b41cfe77
- languageName: node
- linkType: hard
-
-"is-arrayish@npm:^0.2.1":
- version: 0.2.1
- resolution: "is-arrayish@npm:0.2.1"
- checksum: eef4417e3c10e60e2c810b6084942b3ead455af16c4509959a27e490e7aee87cfb3f38e01bbde92220b528a0ee1a18d52b787e1458ee86174d8c7f0e58cd488f
- languageName: node
- linkType: hard
-
-"is-core-module@npm:^2.12.0":
- version: 2.12.1
- resolution: "is-core-module@npm:2.12.1"
- dependencies:
- has: ^1.0.3
- checksum: f04ea30533b5e62764e7b2e049d3157dc0abd95ef44275b32489ea2081176ac9746ffb1cdb107445cf1ff0e0dfcad522726ca27c27ece64dadf3795428b8e468
- languageName: node
- linkType: hard
-
-"is-docker@npm:^2.0.0":
- version: 2.2.1
- resolution: "is-docker@npm:2.2.1"
- bin:
- is-docker: cli.js
- checksum: 3fef7ddbf0be25958e8991ad941901bf5922ab2753c46980b60b05c1bf9c9c2402d35e6dc32e4380b980ef5e1970a5d9d5e5aa2e02d77727c3b6b5e918474c56
- languageName: node
- linkType: hard
-
-"is-extglob@npm:^2.1.1":
- version: 2.1.1
- resolution: "is-extglob@npm:2.1.1"
- checksum: df033653d06d0eb567461e58a7a8c9f940bd8c22274b94bf7671ab36df5719791aae15eef6d83bbb5e23283967f2f984b8914559d4449efda578c775c4be6f85
- languageName: node
- linkType: hard
-
-"is-fullwidth-code-point@npm:^3.0.0":
- version: 3.0.0
- resolution: "is-fullwidth-code-point@npm:3.0.0"
- checksum: 44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348
- languageName: node
- linkType: hard
-
-"is-glob@npm:^4.0.1":
- version: 4.0.3
- resolution: "is-glob@npm:4.0.3"
- dependencies:
- is-extglob: ^2.1.1
- checksum: d381c1319fcb69d341cc6e6c7cd588e17cd94722d9a32dbd60660b993c4fb7d0f19438674e68dfec686d09b7c73139c9166b47597f846af387450224a8101ab4
- languageName: node
- linkType: hard
-
-"is-interactive@npm:^1.0.0":
- version: 1.0.0
- resolution: "is-interactive@npm:1.0.0"
- checksum: 824808776e2d468b2916cdd6c16acacebce060d844c35ca6d82267da692e92c3a16fdba624c50b54a63f38bdc4016055b6f443ce57d7147240de4f8cdabaf6f9
- languageName: node
- linkType: hard
-
-"is-lambda@npm:^1.0.1":
- version: 1.0.1
- resolution: "is-lambda@npm:1.0.1"
- checksum: 93a32f01940220532e5948538699ad610d5924ac86093fcee83022252b363eb0cc99ba53ab084a04e4fb62bf7b5731f55496257a4c38adf87af9c4d352c71c35
- languageName: node
- linkType: hard
-
-"is-my-ip-valid@npm:^1.0.0":
- version: 1.0.1
- resolution: "is-my-ip-valid@npm:1.0.1"
- checksum: 0a50180a9c0842503a2199ca0ba03888069e7c093f71236c65632e9b0f496ea57536856e1ad3d1635010cb5959c551496ea84cfc56088a8e7879fe30b9d71943
- languageName: node
- linkType: hard
-
-"is-my-json-valid@npm:^2.20.0":
- version: 2.20.6
- resolution: "is-my-json-valid@npm:2.20.6"
- dependencies:
- generate-function: ^2.0.0
- generate-object-property: ^1.1.0
- is-my-ip-valid: ^1.0.0
- jsonpointer: ^5.0.0
- xtend: ^4.0.0
- checksum: d3519e18e6a0f4c777d5a2027b5c80d05abd0949179b94795bd2aa6c54e8f44c23b8789cb7d44332015b86cfd73dca57331e7fa53202b28e40aa4620e7f61166
- languageName: node
- linkType: hard
-
-"is-number@npm:^7.0.0":
- version: 7.0.0
- resolution: "is-number@npm:7.0.0"
- checksum: 456ac6f8e0f3111ed34668a624e45315201dff921e5ac181f8ec24923b99e9f32ca1a194912dc79d539c97d33dba17dc635202ff0b2cf98326f608323276d27a
- languageName: node
- linkType: hard
-
-"is-port-reachable@npm:3.1.0":
- version: 3.1.0
- resolution: "is-port-reachable@npm:3.1.0"
- checksum: ce0c872addfe1722a3f1ec6923b9b88b5a370041a10317e1bd76bd62c616feb52c8a6f473e35e7bcf208db22fb5f138433a3a1cd889d95a1f798dbc7a9dc63cf
- languageName: node
- linkType: hard
-
-"is-property@npm:^1.0.0, is-property@npm:^1.0.2":
- version: 1.0.2
- resolution: "is-property@npm:1.0.2"
- checksum: 33b661a3690bcc88f7e47bb0a21b9e3187e76a317541ea7ec5e8096d954f441b77a46d8930c785f7fbf4ef8dfd624c25495221e026e50f74c9048fe501773be5
- languageName: node
- linkType: hard
-
-"is-stream@npm:^1.1.0":
- version: 1.1.0
- resolution: "is-stream@npm:1.1.0"
- checksum: 063c6bec9d5647aa6d42108d4c59723d2bd4ae42135a2d4db6eadbd49b7ea05b750fd69d279e5c7c45cf9da753ad2c00d8978be354d65aa9f6bb434969c6a2ae
- languageName: node
- linkType: hard
-
-"is-unicode-supported@npm:^0.1.0":
- version: 0.1.0
- resolution: "is-unicode-supported@npm:0.1.0"
- checksum: a2aab86ee7712f5c2f999180daaba5f361bdad1efadc9610ff5b8ab5495b86e4f627839d085c6530363c6d6d4ecbde340fb8e54bdb83da4ba8e0865ed5513c52
- languageName: node
- linkType: hard
-
-"is-windows@npm:^1.0.1":
- version: 1.0.2
- resolution: "is-windows@npm:1.0.2"
- checksum: 438b7e52656fe3b9b293b180defb4e448088e7023a523ec21a91a80b9ff8cdb3377ddb5b6e60f7c7de4fa8b63ab56e121b6705fe081b3cf1b828b0a380009ad7
- languageName: node
- linkType: hard
-
-"is-wsl@npm:^2.2.0":
- version: 2.2.0
- resolution: "is-wsl@npm:2.2.0"
- dependencies:
- is-docker: ^2.0.0
- checksum: 20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8
- languageName: node
- linkType: hard
-
-"isbinaryfile@npm:^4.0.8":
- version: 4.0.10
- resolution: "isbinaryfile@npm:4.0.10"
- checksum: a6b28db7e23ac7a77d3707567cac81356ea18bd602a4f21f424f862a31d0e7ab4f250759c98a559ece35ffe4d99f0d339f1ab884ffa9795172f632ab8f88e686
- languageName: node
- linkType: hard
-
-"isexe@npm:^2.0.0":
- version: 2.0.0
- resolution: "isexe@npm:2.0.0"
- checksum: 26bf6c5480dda5161c820c5b5c751ae1e766c587b1f951ea3fcfc973bafb7831ae5b54a31a69bd670220e42e99ec154475025a468eae58ea262f813fdc8d1c62
- languageName: node
- linkType: hard
-
-"jackspeak@npm:^2.0.3":
- version: 2.2.1
- resolution: "jackspeak@npm:2.2.1"
- dependencies:
- "@isaacs/cliui": ^8.0.2
- "@pkgjs/parseargs": ^0.11.0
- dependenciesMeta:
- "@pkgjs/parseargs":
- optional: true
- checksum: e29291c0d0f280a063fa18fbd1e891ab8c2d7519fd34052c0ebde38538a15c603140d60c2c7f432375ff7ee4c5f1c10daa8b2ae19a97c3d4affe308c8360c1df
- languageName: node
- linkType: hard
-
-"json-buffer@npm:3.0.1":
- version: 3.0.1
- resolution: "json-buffer@npm:3.0.1"
- checksum: 9026b03edc2847eefa2e37646c579300a1f3a4586cfb62bf857832b60c852042d0d6ae55d1afb8926163fa54c2b01d83ae24705f34990348bdac6273a29d4581
- languageName: node
- linkType: hard
-
-"json-stringify-safe@npm:^5.0.1":
- version: 5.0.1
- resolution: "json-stringify-safe@npm:5.0.1"
- checksum: 48ec0adad5280b8a96bb93f4563aa1667fd7a36334f79149abd42446d0989f2ddc58274b479f4819f1f00617957e6344c886c55d05a4e15ebb4ab931e4a6a8ee
- languageName: node
- linkType: hard
-
-"jsonfile@npm:^4.0.0":
- version: 4.0.0
- resolution: "jsonfile@npm:4.0.0"
- dependencies:
- graceful-fs: ^4.1.6
- dependenciesMeta:
- graceful-fs:
- optional: true
- checksum: 6447d6224f0d31623eef9b51185af03ac328a7553efcee30fa423d98a9e276ca08db87d71e17f2310b0263fd3ffa6c2a90a6308367f661dc21580f9469897c9e
- languageName: node
- linkType: hard
-
-"jsonfile@npm:^6.0.1":
- version: 6.1.0
- resolution: "jsonfile@npm:6.1.0"
- dependencies:
- graceful-fs: ^4.1.6
- universalify: ^2.0.0
- dependenciesMeta:
- graceful-fs:
- optional: true
- checksum: 7af3b8e1ac8fe7f1eccc6263c6ca14e1966fcbc74b618d3c78a0a2075579487547b94f72b7a1114e844a1e15bb00d440e5d1720bfc4612d790a6f285d5ea8354
- languageName: node
- linkType: hard
-
-"jsonpointer@npm:^5.0.0":
- version: 5.0.1
- resolution: "jsonpointer@npm:5.0.1"
- checksum: 0b40f712900ad0c846681ea2db23b6684b9d5eedf55807b4708c656f5894b63507d0e28ae10aa1bddbea551241035afe62b6df0800fc94c2e2806a7f3adecd7c
- languageName: node
- linkType: hard
-
-"junk@npm:^3.1.0":
- version: 3.1.0
- resolution: "junk@npm:3.1.0"
- checksum: 6c4d68e8f8bc25b546baed802cd0e7be6a971e92f1e885c92cbfe98946d5690b961a32f8e7909e77765d3204c3e556d13c17f73e31697ffae1db07a58b9e68c0
- languageName: node
- linkType: hard
-
-"keyv@npm:^4.0.0":
- version: 4.5.3
- resolution: "keyv@npm:4.5.3"
- dependencies:
- json-buffer: 3.0.1
- checksum: 3ffb4d5b72b6b4b4af443bbb75ca2526b23c750fccb5ac4c267c6116888b4b65681015c2833cb20d26cf3e6e32dac6b988c77f7f022e1a571b7d90f1442257da
- languageName: node
- linkType: hard
-
-"listr2@npm:^5.0.3":
- version: 5.0.8
- resolution: "listr2@npm:5.0.8"
- dependencies:
- cli-truncate: ^2.1.0
- colorette: ^2.0.19
- log-update: ^4.0.0
- p-map: ^4.0.0
- rfdc: ^1.3.0
- rxjs: ^7.8.0
- through: ^2.3.8
- wrap-ansi: ^7.0.0
- peerDependencies:
- enquirer: ">= 2.3.0 < 3"
- peerDependenciesMeta:
- enquirer:
- optional: true
- checksum: 8be9f5632627c4df0dc33f452c98d415a49e5f1614650d3cab1b103c33e95f2a7a0e9f3e1e5de00d51bf0b4179acd8ff11b25be77dbe097cf3773c05e728d46c
- languageName: node
- linkType: hard
-
-"load-json-file@npm:^2.0.0":
- version: 2.0.0
- resolution: "load-json-file@npm:2.0.0"
- dependencies:
- graceful-fs: ^4.1.2
- parse-json: ^2.2.0
- pify: ^2.0.0
- strip-bom: ^3.0.0
- checksum: 7f212bbf08a8c9aab087ead07aa220d1f43d83ec1c4e475a00a8d9bf3014eb29ebe901db8554627dcfb70184c274d05b7379f1e9678fe8297ae74dc495212049
- languageName: node
- linkType: hard
-
-"locate-path@npm:^2.0.0":
- version: 2.0.0
- resolution: "locate-path@npm:2.0.0"
- dependencies:
- p-locate: ^2.0.0
- path-exists: ^3.0.0
- checksum: 02d581edbbbb0fa292e28d96b7de36b5b62c2fa8b5a7e82638ebb33afa74284acf022d3b1e9ae10e3ffb7658fbc49163fcd5e76e7d1baaa7801c3e05a81da755
- languageName: node
- linkType: hard
-
-"locate-path@npm:^5.0.0":
- version: 5.0.0
- resolution: "locate-path@npm:5.0.0"
- dependencies:
- p-locate: ^4.1.0
- checksum: 83e51725e67517287d73e1ded92b28602e3ae5580b301fe54bfb76c0c723e3f285b19252e375712316774cf52006cb236aed5704692c32db0d5d089b69696e30
- languageName: node
- linkType: hard
-
-"locate-path@npm:^6.0.0":
- version: 6.0.0
- resolution: "locate-path@npm:6.0.0"
- dependencies:
- p-locate: ^5.0.0
- checksum: 72eb661788a0368c099a184c59d2fee760b3831c9c1c33955e8a19ae4a21b4116e53fa736dc086cdeb9fce9f7cc508f2f92d2d3aae516f133e16a2bb59a39f5a
- languageName: node
- linkType: hard
-
-"lodash._reinterpolate@npm:^3.0.0":
- version: 3.0.0
- resolution: "lodash._reinterpolate@npm:3.0.0"
- checksum: 06d2d5f33169604fa5e9f27b6067ed9fb85d51a84202a656901e5ffb63b426781a601508466f039c720af111b0c685d12f1a5c14ff8df5d5f27e491e562784b2
- languageName: node
- linkType: hard
-
-"lodash.get@npm:^4.0.0":
- version: 4.4.2
- resolution: "lodash.get@npm:4.4.2"
- checksum: e403047ddb03181c9d0e92df9556570e2b67e0f0a930fcbbbd779370972368f5568e914f913e93f3b08f6d492abc71e14d4e9b7a18916c31fa04bd2306efe545
- languageName: node
- linkType: hard
-
-"lodash.template@npm:^4.2.2":
- version: 4.5.0
- resolution: "lodash.template@npm:4.5.0"
- dependencies:
- lodash._reinterpolate: ^3.0.0
- lodash.templatesettings: ^4.0.0
- checksum: ca64e5f07b6646c9d3dbc0fe3aaa995cb227c4918abd1cef7a9024cd9c924f2fa389a0ec4296aa6634667e029bc81d4bbdb8efbfde11df76d66085e6c529b450
- languageName: node
- linkType: hard
-
-"lodash.templatesettings@npm:^4.0.0":
- version: 4.2.0
- resolution: "lodash.templatesettings@npm:4.2.0"
- dependencies:
- lodash._reinterpolate: ^3.0.0
- checksum: 863e025478b092997e11a04e9d9e735875eeff1ffcd6c61742aa8272e3c2cddc89ce795eb9726c4e74cef5991f722897ff37df7738a125895f23fc7d12a7bb59
- languageName: node
- linkType: hard
-
-"lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.4":
- version: 4.17.21
- resolution: "lodash@npm:4.17.21"
- checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7
- languageName: node
- linkType: hard
-
-"log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0":
- version: 4.1.0
- resolution: "log-symbols@npm:4.1.0"
- dependencies:
- chalk: ^4.1.0
- is-unicode-supported: ^0.1.0
- checksum: fce1497b3135a0198803f9f07464165e9eb83ed02ceb2273930a6f8a508951178d8cf4f0378e9d28300a2ed2bc49050995d2bd5f53ab716bb15ac84d58c6ef74
- languageName: node
- linkType: hard
-
-"log-update@npm:^4.0.0":
- version: 4.0.0
- resolution: "log-update@npm:4.0.0"
- dependencies:
- ansi-escapes: ^4.3.0
- cli-cursor: ^3.1.0
- slice-ansi: ^4.0.0
- wrap-ansi: ^6.2.0
- checksum: ae2f85bbabc1906034154fb7d4c4477c79b3e703d22d78adee8b3862fa913942772e7fa11713e3d96fb46de4e3cabefbf5d0a544344f03b58d3c4bff52aa9eb2
- languageName: node
- linkType: hard
-
-"lowercase-keys@npm:^2.0.0":
- version: 2.0.0
- resolution: "lowercase-keys@npm:2.0.0"
- checksum: 24d7ebd56ccdf15ff529ca9e08863f3c54b0b9d1edb97a3ae1af34940ae666c01a1e6d200707bce730a8ef76cb57cc10e65f245ecaaf7e6bc8639f2fb460ac23
- languageName: node
- linkType: hard
-
-"lru-cache@npm:^6.0.0":
- version: 6.0.0
- resolution: "lru-cache@npm:6.0.0"
- dependencies:
- yallist: ^4.0.0
- checksum: f97f499f898f23e4585742138a22f22526254fdba6d75d41a1c2526b3b6cc5747ef59c5612ba7375f42aca4f8461950e925ba08c991ead0651b4918b7c978297
- languageName: node
- linkType: hard
-
-"lru-cache@npm:^7.7.1":
- version: 7.18.3
- resolution: "lru-cache@npm:7.18.3"
- checksum: e550d772384709deea3f141af34b6d4fa392e2e418c1498c078de0ee63670f1f46f5eee746e8ef7e69e1c895af0d4224e62ee33e66a543a14763b0f2e74c1356
- languageName: node
- linkType: hard
-
-"lru-cache@npm:^9.1.1 || ^10.0.0":
- version: 10.0.0
- resolution: "lru-cache@npm:10.0.0"
- checksum: 18f101675fe283bc09cda0ef1e3cc83781aeb8373b439f086f758d1d91b28730950db785999cd060d3c825a8571c03073e8c14512b6655af2188d623031baf50
- languageName: node
- linkType: hard
-
-"macos-alias@npm:~0.2.5":
- version: 0.2.11
- resolution: "macos-alias@npm:0.2.11"
- dependencies:
- nan: ^2.4.0
- node-gyp: latest
- conditions: os=darwin
- languageName: node
- linkType: hard
-
-"make-fetch-happen@npm:^11.0.3":
- version: 11.1.1
- resolution: "make-fetch-happen@npm:11.1.1"
- dependencies:
- agentkeepalive: ^4.2.1
- cacache: ^17.0.0
- http-cache-semantics: ^4.1.1
- http-proxy-agent: ^5.0.0
- https-proxy-agent: ^5.0.0
- is-lambda: ^1.0.1
- lru-cache: ^7.7.1
- minipass: ^5.0.0
- minipass-fetch: ^3.0.0
- minipass-flush: ^1.0.5
- minipass-pipeline: ^1.2.4
- negotiator: ^0.6.3
- promise-retry: ^2.0.1
- socks-proxy-agent: ^7.0.0
- ssri: ^10.0.0
- checksum: 7268bf274a0f6dcf0343829489a4506603ff34bd0649c12058753900b0eb29191dce5dba12680719a5d0a983d3e57810f594a12f3c18494e93a1fbc6348a4540
- languageName: node
- linkType: hard
-
-"map-age-cleaner@npm:^0.1.1":
- version: 0.1.3
- resolution: "map-age-cleaner@npm:0.1.3"
- dependencies:
- p-defer: ^1.0.0
- checksum: cb2804a5bcb3cbdfe4b59066ea6d19f5e7c8c196cd55795ea4c28f792b192e4c442426ae52524e5e1acbccf393d3bddacefc3d41f803e66453f6c4eda3650bc1
- languageName: node
- linkType: hard
-
-"matcher@npm:^3.0.0":
- version: 3.0.0
- resolution: "matcher@npm:3.0.0"
- dependencies:
- escape-string-regexp: ^4.0.0
- checksum: 8bee1a7ab7609c2c21d9c9254b6785fa708eadf289032b556d57a34e98fcd4c537659a004dafee6ce80ab157099e645c199dc52678dff1e7fb0a6684e0da4dbe
- languageName: node
- linkType: hard
-
-"media-typer@npm:0.3.0":
- version: 0.3.0
- resolution: "media-typer@npm:0.3.0"
- checksum: af1b38516c28ec95d6b0826f6c8f276c58aec391f76be42aa07646b4e39d317723e869700933ca6995b056db4b09a78c92d5440dc23657e6764be5d28874bba1
- languageName: node
- linkType: hard
-
-"mem@npm:^4.3.0":
- version: 4.3.0
- resolution: "mem@npm:4.3.0"
- dependencies:
- map-age-cleaner: ^0.1.1
- mimic-fn: ^2.0.0
- p-is-promise: ^2.0.0
- checksum: cf488608e5d59c6cb68004b70de317222d4be9f857fd535dfa6a108e04f40821479c080bc763c417b1030569d303538c59d441280078cfce07fefd1c523f98ef
- languageName: node
- linkType: hard
-
-"merge-descriptors@npm:1.0.1":
- version: 1.0.1
- resolution: "merge-descriptors@npm:1.0.1"
- checksum: 5abc259d2ae25bb06d19ce2b94a21632583c74e2a9109ee1ba7fd147aa7362b380d971e0251069f8b3eb7d48c21ac839e21fa177b335e82c76ec172e30c31a26
- languageName: node
- linkType: hard
-
-"merge2@npm:^1.3.0":
- version: 1.4.1
- resolution: "merge2@npm:1.4.1"
- checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2
- languageName: node
- linkType: hard
-
-"methods@npm:~1.1.2":
- version: 1.1.2
- resolution: "methods@npm:1.1.2"
- checksum: 0917ff4041fa8e2f2fda5425a955fe16ca411591fbd123c0d722fcf02b73971ed6f764d85f0a6f547ce49ee0221ce2c19a5fa692157931cecb422984f1dcd13a
- languageName: node
- linkType: hard
-
-"micromatch@npm:^4.0.4":
- version: 4.0.5
- resolution: "micromatch@npm:4.0.5"
- dependencies:
- braces: ^3.0.2
- picomatch: ^2.3.1
- checksum: 02a17b671c06e8fefeeb6ef996119c1e597c942e632a21ef589154f23898c9c6a9858526246abb14f8bca6e77734aa9dcf65476fca47cedfb80d9577d52843fc
- languageName: node
- linkType: hard
-
-"mime-db@npm:1.52.0":
- version: 1.52.0
- resolution: "mime-db@npm:1.52.0"
- checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f
- languageName: node
- linkType: hard
-
-"mime-types@npm:~2.1.24, mime-types@npm:~2.1.34":
- version: 2.1.35
- resolution: "mime-types@npm:2.1.35"
- dependencies:
- mime-db: 1.52.0
- checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836
- languageName: node
- linkType: hard
-
-"mime@npm:1.6.0":
- version: 1.6.0
- resolution: "mime@npm:1.6.0"
- bin:
- mime: cli.js
- checksum: fef25e39263e6d207580bdc629f8872a3f9772c923c7f8c7e793175cee22777bbe8bba95e5d509a40aaa292d8974514ce634ae35769faa45f22d17edda5e8557
- languageName: node
- linkType: hard
-
-"mimic-fn@npm:^2.0.0, mimic-fn@npm:^2.1.0":
- version: 2.1.0
- resolution: "mimic-fn@npm:2.1.0"
- checksum: d2421a3444848ce7f84bd49115ddacff29c15745db73f54041edc906c14b131a38d05298dae3081667627a59b2eb1ca4b436ff2e1b80f69679522410418b478a
- languageName: node
- linkType: hard
-
-"mimic-response@npm:^1.0.0":
- version: 1.0.1
- resolution: "mimic-response@npm:1.0.1"
- checksum: 034c78753b0e622bc03c983663b1cdf66d03861050e0c8606563d149bc2b02d63f62ce4d32be4ab50d0553ae0ffe647fc34d1f5281184c6e1e8cf4d85e8d9823
- languageName: node
- linkType: hard
-
-"mimic-response@npm:^3.1.0":
- version: 3.1.0
- resolution: "mimic-response@npm:3.1.0"
- checksum: 25739fee32c17f433626bf19f016df9036b75b3d84a3046c7d156e72ec963dd29d7fc8a302f55a3d6c5a4ff24259676b15d915aad6480815a969ff2ec0836867
- languageName: node
- linkType: hard
-
-"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1":
- version: 3.1.2
- resolution: "minimatch@npm:3.1.2"
- dependencies:
- brace-expansion: ^1.1.7
- checksum: c154e566406683e7bcb746e000b84d74465b3a832c45d59912b9b55cd50dee66e5c4b1e5566dba26154040e51672f9aa450a9aef0c97cfc7336b78b7afb9540a
- languageName: node
- linkType: hard
-
-"minimatch@npm:^9.0.1":
- version: 9.0.3
- resolution: "minimatch@npm:9.0.3"
- dependencies:
- brace-expansion: ^2.0.1
- checksum: 253487976bf485b612f16bf57463520a14f512662e592e95c571afdab1442a6a6864b6c88f248ce6fc4ff0b6de04ac7aa6c8bb51e868e99d1d65eb0658a708b5
- languageName: node
- linkType: hard
-
-"minimist@npm:^1.1.1, minimist@npm:^1.1.3, minimist@npm:^1.2.6":
- version: 1.2.8
- resolution: "minimist@npm:1.2.8"
- checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0
- languageName: node
- linkType: hard
-
-"minipass-collect@npm:^1.0.2":
- version: 1.0.2
- resolution: "minipass-collect@npm:1.0.2"
- dependencies:
- minipass: ^3.0.0
- checksum: 14df761028f3e47293aee72888f2657695ec66bd7d09cae7ad558da30415fdc4752bbfee66287dcc6fd5e6a2fa3466d6c484dc1cbd986525d9393b9523d97f10
- languageName: node
- linkType: hard
-
-"minipass-fetch@npm:^3.0.0":
- version: 3.0.3
- resolution: "minipass-fetch@npm:3.0.3"
- dependencies:
- encoding: ^0.1.13
- minipass: ^5.0.0
- minipass-sized: ^1.0.3
- minizlib: ^2.1.2
- dependenciesMeta:
- encoding:
- optional: true
- checksum: af5ab2552a16fcf505d35fd7ffb84b57f4a0eeb269e6e1d9a2a75824dda48b36e527083250b7cca4a4def21d9544e2ade441e4730e233c0bc2133f6abda31e18
- languageName: node
- linkType: hard
-
-"minipass-flush@npm:^1.0.5":
- version: 1.0.5
- resolution: "minipass-flush@npm:1.0.5"
- dependencies:
- minipass: ^3.0.0
- checksum: 56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf
- languageName: node
- linkType: hard
-
-"minipass-pipeline@npm:^1.2.4":
- version: 1.2.4
- resolution: "minipass-pipeline@npm:1.2.4"
- dependencies:
- minipass: ^3.0.0
- checksum: b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b
- languageName: node
- linkType: hard
-
-"minipass-sized@npm:^1.0.3":
- version: 1.0.3
- resolution: "minipass-sized@npm:1.0.3"
- dependencies:
- minipass: ^3.0.0
- checksum: 79076749fcacf21b5d16dd596d32c3b6bf4d6e62abb43868fac21674078505c8b15eaca4e47ed844985a4514854f917d78f588fcd029693709417d8f98b2bd60
- languageName: node
- linkType: hard
-
-"minipass@npm:^3.0.0":
- version: 3.3.6
- resolution: "minipass@npm:3.3.6"
- dependencies:
- yallist: ^4.0.0
- checksum: a30d083c8054cee83cdcdc97f97e4641a3f58ae743970457b1489ce38ee1167b3aaf7d815cd39ec7a99b9c40397fd4f686e83750e73e652b21cb516f6d845e48
- languageName: node
- linkType: hard
-
-"minipass@npm:^5.0.0":
- version: 5.0.0
- resolution: "minipass@npm:5.0.0"
- checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea
- languageName: node
- linkType: hard
-
-"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0":
- version: 7.0.2
- resolution: "minipass@npm:7.0.2"
- checksum: 46776de732eb7cef2c7404a15fb28c41f5c54a22be50d47b03c605bf21f5c18d61a173c0a20b49a97e7a65f78d887245066410642551e45fffe04e9ac9e325bc
- languageName: node
- linkType: hard
-
-"minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
- version: 2.1.2
- resolution: "minizlib@npm:2.1.2"
- dependencies:
- minipass: ^3.0.0
- yallist: ^4.0.0
- checksum: f1fdeac0b07cf8f30fcf12f4b586795b97be856edea22b5e9072707be51fc95d41487faec3f265b42973a304fe3a64acd91a44a3826a963e37b37bafde0212c3
- languageName: node
- linkType: hard
-
-"mkdirp@npm:^0.5.1":
- version: 0.5.6
- resolution: "mkdirp@npm:0.5.6"
- dependencies:
- minimist: ^1.2.6
- bin:
- mkdirp: bin/cmd.js
- checksum: 0c91b721bb12c3f9af4b77ebf73604baf350e64d80df91754dc509491ae93bf238581e59c7188360cec7cb62fc4100959245a42cfe01834efedc5e9d068376c2
- languageName: node
- linkType: hard
-
-"mkdirp@npm:^1.0.3":
- version: 1.0.4
- resolution: "mkdirp@npm:1.0.4"
- bin:
- mkdirp: bin/cmd.js
- checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f
- languageName: node
- linkType: hard
-
-"ms@npm:2.0.0":
- version: 2.0.0
- resolution: "ms@npm:2.0.0"
- checksum: 0e6a22b8b746d2e0b65a430519934fefd41b6db0682e3477c10f60c76e947c4c0ad06f63ffdf1d78d335f83edee8c0aa928aa66a36c7cd95b69b26f468d527f4
- languageName: node
- linkType: hard
-
-"ms@npm:2.1.2":
- version: 2.1.2
- resolution: "ms@npm:2.1.2"
- checksum: 673cdb2c3133eb050c745908d8ce632ed2c02d85640e2edb3ace856a2266a813b30c613569bf3354fdf4ea7d1a1494add3bfa95e2713baa27d0c2c71fc44f58f
- languageName: node
- linkType: hard
-
-"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1":
- version: 2.1.3
- resolution: "ms@npm:2.1.3"
- checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d
- languageName: node
- linkType: hard
-
-"murmur-32@npm:^0.1.0 || ^0.2.0":
- version: 0.2.0
- resolution: "murmur-32@npm:0.2.0"
- dependencies:
- encode-utf8: ^1.0.3
- fmix: ^0.1.0
- imul: ^1.0.0
- checksum: 664f19319c23b2910bd6b4d79e072c910168b157c26bf4507c78f0c7a259cb6f6233fb04eca7d02b271491a8f87660d5c4619f35f7411d9ab10fca715fa93f7c
- languageName: node
- linkType: hard
-
-"nan@npm:^2.4.0":
- version: 2.17.0
- resolution: "nan@npm:2.17.0"
- dependencies:
- node-gyp: latest
- checksum: ec609aeaf7e68b76592a3ba96b372aa7f5df5b056c1e37410b0f1deefbab5a57a922061e2c5b369bae9c7c6b5e6eecf4ad2dac8833a1a7d3a751e0a7c7f849ed
- languageName: node
- linkType: hard
-
-"nanoid@npm:^3.3.6":
- version: 3.3.6
- resolution: "nanoid@npm:3.3.6"
- bin:
- nanoid: bin/nanoid.cjs
- checksum: 7d0eda657002738aa5206107bd0580aead6c95c460ef1bdd0b1a87a9c7ae6277ac2e9b945306aaa5b32c6dcb7feaf462d0f552e7f8b5718abfc6ead5c94a71b3
- languageName: node
- linkType: hard
-
-"negotiator@npm:0.6.3, negotiator@npm:^0.6.3":
- version: 0.6.3
- resolution: "negotiator@npm:0.6.3"
- checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9
- languageName: node
- linkType: hard
-
-"nice-try@npm:^1.0.4":
- version: 1.0.5
- resolution: "nice-try@npm:1.0.5"
- checksum: 0b4af3b5bb5d86c289f7a026303d192a7eb4417231fe47245c460baeabae7277bcd8fd9c728fb6bd62c30b3e15cd6620373e2cf33353b095d8b403d3e8a15aff
- languageName: node
- linkType: hard
-
-"node-abi@npm:^3.0.0":
- version: 3.45.0
- resolution: "node-abi@npm:3.45.0"
- dependencies:
- semver: ^7.3.5
- checksum: 18c4305d7de5f1132741a2a66ba652941518210d02c9268702abe97ce1c166db468b4fc3e85fff04b9c19218c2e47f4e295f9a46422dc834932f4e11443400cd
- languageName: node
- linkType: hard
-
-"node-api-version@npm:^0.1.4":
- version: 0.1.4
- resolution: "node-api-version@npm:0.1.4"
- dependencies:
- semver: ^7.3.5
- checksum: e652a9502a6b62bda01d6134be30195f9d8b3ba75190a4190c76e7ed4f12a410cdc7ec301f878aff11dafc14bc7d9c4fc81f88c1e174c8fb970b7b33eb978b98
- languageName: node
- linkType: hard
-
-"node-fetch@npm:^2.6.7":
- version: 2.6.12
- resolution: "node-fetch@npm:2.6.12"
- dependencies:
- whatwg-url: ^5.0.0
- peerDependencies:
- encoding: ^0.1.0
- peerDependenciesMeta:
- encoding:
- optional: true
- checksum: 3bc1655203d47ee8e313c0d96664b9673a3d4dd8002740318e9d27d14ef306693a4b2ef8d6525775056fd912a19e23f3ac0d7111ad8925877b7567b29a625592
- languageName: node
- linkType: hard
-
-"node-gyp@npm:^9.0.0, node-gyp@npm:latest":
- version: 9.4.0
- resolution: "node-gyp@npm:9.4.0"
- dependencies:
- env-paths: ^2.2.0
- exponential-backoff: ^3.1.1
- glob: ^7.1.4
- graceful-fs: ^4.2.6
- make-fetch-happen: ^11.0.3
- nopt: ^6.0.0
- npmlog: ^6.0.0
- rimraf: ^3.0.2
- semver: ^7.3.5
- tar: ^6.1.2
- which: ^2.0.2
- bin:
- node-gyp: bin/node-gyp.js
- checksum: 78b404e2e0639d64e145845f7f5a3cb20c0520cdaf6dda2f6e025e9b644077202ea7de1232396ba5bde3fee84cdc79604feebe6ba3ec84d464c85d407bb5da99
- languageName: node
- linkType: hard
-
-"nopt@npm:^6.0.0":
- version: 6.0.0
- resolution: "nopt@npm:6.0.0"
- dependencies:
- abbrev: ^1.0.0
- bin:
- nopt: bin/nopt.js
- checksum: 82149371f8be0c4b9ec2f863cc6509a7fd0fa729929c009f3a58e4eb0c9e4cae9920e8f1f8eb46e7d032fec8fb01bede7f0f41a67eb3553b7b8e14fa53de1dac
- languageName: node
- linkType: hard
-
-"normalize-package-data@npm:^2.3.2":
- version: 2.5.0
- resolution: "normalize-package-data@npm:2.5.0"
- dependencies:
- hosted-git-info: ^2.1.4
- resolve: ^1.10.0
- semver: 2 || 3 || 4 || 5
- validate-npm-package-license: ^3.0.1
- checksum: 7999112efc35a6259bc22db460540cae06564aa65d0271e3bdfa86876d08b0e578b7b5b0028ee61b23f1cae9fc0e7847e4edc0948d3068a39a2a82853efc8499
- languageName: node
- linkType: hard
-
-"normalize-url@npm:^6.0.1":
- version: 6.1.0
- resolution: "normalize-url@npm:6.1.0"
- checksum: 4a4944631173e7d521d6b80e4c85ccaeceb2870f315584fa30121f505a6dfd86439c5e3fdd8cd9e0e291290c41d0c3599f0cb12ab356722ed242584c30348e50
- languageName: node
- linkType: hard
-
-"npm-run-path@npm:^2.0.0":
- version: 2.0.2
- resolution: "npm-run-path@npm:2.0.2"
- dependencies:
- path-key: ^2.0.0
- checksum: acd5ad81648ba4588ba5a8effb1d98d2b339d31be16826a118d50f182a134ac523172101b82eab1d01cb4c2ba358e857d54cfafd8163a1ffe7bd52100b741125
- languageName: node
- linkType: hard
-
-"npmlog@npm:^6.0.0":
- version: 6.0.2
- resolution: "npmlog@npm:6.0.2"
- dependencies:
- are-we-there-yet: ^3.0.0
- console-control-strings: ^1.1.0
- gauge: ^4.0.3
- set-blocking: ^2.0.0
- checksum: ae238cd264a1c3f22091cdd9e2b106f684297d3c184f1146984ecbe18aaa86343953f26b9520dedd1b1372bc0316905b736c1932d778dbeb1fcf5a1001390e2a
- languageName: node
- linkType: hard
-
-"object-inspect@npm:^1.9.0":
- version: 1.12.3
- resolution: "object-inspect@npm:1.12.3"
- checksum: dabfd824d97a5f407e6d5d24810d888859f6be394d8b733a77442b277e0808860555176719c5905e765e3743a7cada6b8b0a3b85e5331c530fd418cc8ae991db
- languageName: node
- linkType: hard
-
-"object-keys@npm:^1.1.1":
- version: 1.1.1
- resolution: "object-keys@npm:1.1.1"
- checksum: b363c5e7644b1e1b04aa507e88dcb8e3a2f52b6ffd0ea801e4c7a62d5aa559affe21c55a07fd4b1fd55fc03a33c610d73426664b20032405d7b92a1414c34d6a
- languageName: node
- linkType: hard
-
-"on-finished@npm:2.4.1":
- version: 2.4.1
- resolution: "on-finished@npm:2.4.1"
- dependencies:
- ee-first: 1.1.1
- checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0
- languageName: node
- linkType: hard
-
-"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0":
- version: 1.4.0
- resolution: "once@npm:1.4.0"
- dependencies:
- wrappy: 1
- checksum: cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68
- languageName: node
- linkType: hard
-
-"onetime@npm:^5.1.0":
- version: 5.1.2
- resolution: "onetime@npm:5.1.2"
- dependencies:
- mimic-fn: ^2.1.0
- checksum: 2478859ef817fc5d4e9c2f9e5728512ddd1dbc9fb7829ad263765bb6d3b91ce699d6e2332eef6b7dff183c2f490bd3349f1666427eaba4469fba0ac38dfd0d34
- languageName: node
- linkType: hard
-
-"ora@npm:^5.1.0":
- version: 5.4.1
- resolution: "ora@npm:5.4.1"
- dependencies:
- bl: ^4.1.0
- chalk: ^4.1.0
- cli-cursor: ^3.1.0
- cli-spinners: ^2.5.0
- is-interactive: ^1.0.0
- is-unicode-supported: ^0.1.0
- log-symbols: ^4.1.0
- strip-ansi: ^6.0.0
- wcwidth: ^1.0.1
- checksum: 28d476ee6c1049d68368c0dc922e7225e3b5600c3ede88fade8052837f9ed342625fdaa84a6209302587c8ddd9b664f71f0759833cbdb3a4cf81344057e63c63
- languageName: node
- linkType: hard
-
-"p-cancelable@npm:^2.0.0":
- version: 2.1.1
- resolution: "p-cancelable@npm:2.1.1"
- checksum: 3dba12b4fb4a1e3e34524535c7858fc82381bbbd0f247cc32dedc4018592a3950ce66b106d0880b4ec4c2d8d6576f98ca885dc1d7d0f274d1370be20e9523ddf
- languageName: node
- linkType: hard
-
-"p-defer@npm:^1.0.0":
- version: 1.0.0
- resolution: "p-defer@npm:1.0.0"
- checksum: 4271b935c27987e7b6f229e5de4cdd335d808465604644cb7b4c4c95bef266735859a93b16415af8a41fd663ee9e3b97a1a2023ca9def613dba1bad2a0da0c7b
- languageName: node
- linkType: hard
-
-"p-finally@npm:^1.0.0":
- version: 1.0.0
- resolution: "p-finally@npm:1.0.0"
- checksum: 93a654c53dc805dd5b5891bab16eb0ea46db8f66c4bfd99336ae929323b1af2b70a8b0654f8f1eae924b2b73d037031366d645f1fd18b3d30cbd15950cc4b1d4
- languageName: node
- linkType: hard
-
-"p-is-promise@npm:^2.0.0":
- version: 2.1.0
- resolution: "p-is-promise@npm:2.1.0"
- checksum: c9a8248c8b5e306475a5d55ce7808dbce4d4da2e3d69526e4991a391a7809bfd6cfdadd9bf04f1c96a3db366c93d9a0f5ee81d949e7b1684c4e0f61f747199ef
- languageName: node
- linkType: hard
-
-"p-limit@npm:^1.1.0":
- version: 1.3.0
- resolution: "p-limit@npm:1.3.0"
- dependencies:
- p-try: ^1.0.0
- checksum: 281c1c0b8c82e1ac9f81acd72a2e35d402bf572e09721ce5520164e9de07d8274451378a3470707179ad13240535558f4b277f02405ad752e08c7d5b0d54fbfd
- languageName: node
- linkType: hard
-
-"p-limit@npm:^2.2.0":
- version: 2.3.0
- resolution: "p-limit@npm:2.3.0"
- dependencies:
- p-try: ^2.0.0
- checksum: 84ff17f1a38126c3314e91ecfe56aecbf36430940e2873dadaa773ffe072dc23b7af8e46d4b6485d302a11673fe94c6b67ca2cfbb60c989848b02100d0594ac1
- languageName: node
- linkType: hard
-
-"p-limit@npm:^3.0.2":
- version: 3.1.0
- resolution: "p-limit@npm:3.1.0"
- dependencies:
- yocto-queue: ^0.1.0
- checksum: 7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360
- languageName: node
- linkType: hard
-
-"p-locate@npm:^2.0.0":
- version: 2.0.0
- resolution: "p-locate@npm:2.0.0"
- dependencies:
- p-limit: ^1.1.0
- checksum: e2dceb9b49b96d5513d90f715780f6f4972f46987dc32a0e18bc6c3fc74a1a5d73ec5f81b1398af5e58b99ea1ad03fd41e9181c01fa81b4af2833958696e3081
- languageName: node
- linkType: hard
-
-"p-locate@npm:^4.1.0":
- version: 4.1.0
- resolution: "p-locate@npm:4.1.0"
- dependencies:
- p-limit: ^2.2.0
- checksum: 513bd14a455f5da4ebfcb819ef706c54adb09097703de6aeaa5d26fe5ea16df92b48d1ac45e01e3944ce1e6aa2a66f7f8894742b8c9d6e276e16cd2049a2b870
- languageName: node
- linkType: hard
-
-"p-locate@npm:^5.0.0":
- version: 5.0.0
- resolution: "p-locate@npm:5.0.0"
- dependencies:
- p-limit: ^3.0.2
- checksum: 1623088f36cf1cbca58e9b61c4e62bf0c60a07af5ae1ca99a720837356b5b6c5ba3eb1b2127e47a06865fee59dd0453cad7cc844cda9d5a62ac1a5a51b7c86d3
- languageName: node
- linkType: hard
-
-"p-map@npm:^4.0.0":
- version: 4.0.0
- resolution: "p-map@npm:4.0.0"
- dependencies:
- aggregate-error: ^3.0.0
- checksum: cb0ab21ec0f32ddffd31dfc250e3afa61e103ef43d957cc45497afe37513634589316de4eb88abdfd969fe6410c22c0b93ab24328833b8eb1ccc087fc0442a1c
- languageName: node
- linkType: hard
-
-"p-try@npm:^1.0.0":
- version: 1.0.0
- resolution: "p-try@npm:1.0.0"
- checksum: 3b5303f77eb7722144154288bfd96f799f8ff3e2b2b39330efe38db5dd359e4fb27012464cd85cb0a76e9b7edd1b443568cb3192c22e7cffc34989df0bafd605
- languageName: node
- linkType: hard
-
-"p-try@npm:^2.0.0":
- version: 2.2.0
- resolution: "p-try@npm:2.2.0"
- checksum: f8a8e9a7693659383f06aec604ad5ead237c7a261c18048a6e1b5b85a5f8a067e469aa24f5bc009b991ea3b058a87f5065ef4176793a200d4917349881216cae
- languageName: node
- linkType: hard
-
-"parse-author@npm:^2.0.0":
- version: 2.0.0
- resolution: "parse-author@npm:2.0.0"
- dependencies:
- author-regex: ^1.0.0
- checksum: 066ad615de7dbc3c4293eaaf66a65ea81f8e75e2cffcaf9dd3bcdd4dc4cfff1baa3c85bb3adbedfbed2ddee3298ef4e25ef51b524e91d5a5815d8d9598d31367
- languageName: node
- linkType: hard
-
-"parse-color@npm:^1.0.0":
- version: 1.0.0
- resolution: "parse-color@npm:1.0.0"
- dependencies:
- color-convert: ~0.5.0
- checksum: 0e6e1821eacb4cd21dff380eceafa229052fe22b9951a891c7cac6080a681f29cb2ac50050398ae6cba089cde87f640bcaf8439bf16d468de029691275c175ef
- languageName: node
- linkType: hard
-
-"parse-json@npm:^2.2.0":
- version: 2.2.0
- resolution: "parse-json@npm:2.2.0"
- dependencies:
- error-ex: ^1.2.0
- checksum: dda78a63e57a47b713a038630868538f718a7ca0cd172a36887b0392ccf544ed0374902eb28f8bf3409e8b71d62b79d17062f8543afccf2745f9b0b2d2bb80ca
- languageName: node
- linkType: hard
-
-"parse-passwd@npm:^1.0.0":
- version: 1.0.0
- resolution: "parse-passwd@npm:1.0.0"
- checksum: 4e55e0231d58f828a41d0f1da2bf2ff7bcef8f4cb6146e69d16ce499190de58b06199e6bd9b17fbf0d4d8aef9052099cdf8c4f13a6294b1a522e8e958073066e
- languageName: node
- linkType: hard
-
-"parseurl@npm:~1.3.3":
- version: 1.3.3
- resolution: "parseurl@npm:1.3.3"
- checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2
- languageName: node
- linkType: hard
-
-"path-exists@npm:^3.0.0":
- version: 3.0.0
- resolution: "path-exists@npm:3.0.0"
- checksum: 96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a
- languageName: node
- linkType: hard
-
-"path-exists@npm:^4.0.0":
- version: 4.0.0
- resolution: "path-exists@npm:4.0.0"
- checksum: 505807199dfb7c50737b057dd8d351b82c033029ab94cb10a657609e00c1bc53b951cfdbccab8de04c5584d5eff31128ce6afd3db79281874a5ef2adbba55ed1
- languageName: node
- linkType: hard
-
-"path-is-absolute@npm:^1.0.0":
- version: 1.0.1
- resolution: "path-is-absolute@npm:1.0.1"
- checksum: 060840f92cf8effa293bcc1bea81281bd7d363731d214cbe5c227df207c34cd727430f70c6037b5159c8a870b9157cba65e775446b0ab06fd5ecc7e54615a3b8
- languageName: node
- linkType: hard
-
-"path-key@npm:^2.0.0, path-key@npm:^2.0.1":
- version: 2.0.1
- resolution: "path-key@npm:2.0.1"
- checksum: f7ab0ad42fe3fb8c7f11d0c4f849871e28fbd8e1add65c370e422512fc5887097b9cf34d09c1747d45c942a8c1e26468d6356e2df3f740bf177ab8ca7301ebfd
- languageName: node
- linkType: hard
-
-"path-key@npm:^3.1.0":
- version: 3.1.1
- resolution: "path-key@npm:3.1.1"
- checksum: 55cd7a9dd4b343412a8386a743f9c746ef196e57c823d90ca3ab917f90ab9f13dd0ded27252ba49dbdfcab2b091d998bc446f6220cd3cea65db407502a740020
- languageName: node
- linkType: hard
-
-"path-parse@npm:^1.0.7":
- version: 1.0.7
- resolution: "path-parse@npm:1.0.7"
- checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a
- languageName: node
- linkType: hard
-
-"path-scurry@npm:^1.10.1":
- version: 1.10.1
- resolution: "path-scurry@npm:1.10.1"
- dependencies:
- lru-cache: ^9.1.1 || ^10.0.0
- minipass: ^5.0.0 || ^6.0.2 || ^7.0.0
- checksum: e2557cff3a8fb8bc07afdd6ab163a92587884f9969b05bbbaf6fe7379348bfb09af9ed292af12ed32398b15fb443e81692047b786d1eeb6d898a51eb17ed7d90
- languageName: node
- linkType: hard
-
-"path-to-regexp@npm:0.1.7":
- version: 0.1.7
- resolution: "path-to-regexp@npm:0.1.7"
- checksum: 69a14ea24db543e8b0f4353305c5eac6907917031340e5a8b37df688e52accd09e3cebfe1660b70d76b6bd89152f52183f28c74813dbf454ba1a01c82a38abce
- languageName: node
- linkType: hard
-
-"path-type@npm:^2.0.0":
- version: 2.0.0
- resolution: "path-type@npm:2.0.0"
- dependencies:
- pify: ^2.0.0
- checksum: 749dc0c32d4ebe409da155a0022f9be3d08e6fd276adb3dfa27cb2486519ab2aa277d1453b3fde050831e0787e07b0885a75653fefcc82d883753c5b91121b1c
- languageName: node
- linkType: hard
-
-"pend@npm:~1.2.0":
- version: 1.2.0
- resolution: "pend@npm:1.2.0"
- checksum: 6c72f5243303d9c60bd98e6446ba7d30ae29e3d56fdb6fae8767e8ba6386f33ee284c97efe3230a0d0217e2b1723b8ab490b1bbf34fcbb2180dbc8a9de47850d
- languageName: node
- linkType: hard
-
-"picocolors@npm:^1.0.0":
- version: 1.0.0
- resolution: "picocolors@npm:1.0.0"
- checksum: a2e8092dd86c8396bdba9f2b5481032848525b3dc295ce9b57896f931e63fc16f79805144321f72976383fc249584672a75cc18d6777c6b757603f372f745981
- languageName: node
- linkType: hard
-
-"picomatch@npm:^2.3.1":
- version: 2.3.1
- resolution: "picomatch@npm:2.3.1"
- checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf
- languageName: node
- linkType: hard
-
-"pify@npm:^2.0.0":
- version: 2.3.0
- resolution: "pify@npm:2.3.0"
- checksum: 9503aaeaf4577acc58642ad1d25c45c6d90288596238fb68f82811c08104c800e5a7870398e9f015d82b44ecbcbef3dc3d4251a1cbb582f6e5959fe09884b2ba
- languageName: node
- linkType: hard
-
-"pkg-dir@npm:^4.2.0":
- version: 4.2.0
- resolution: "pkg-dir@npm:4.2.0"
- dependencies:
- find-up: ^4.0.0
- checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6
- languageName: node
- linkType: hard
-
-"plist@npm:^3.0.0, plist@npm:^3.0.4, plist@npm:^3.0.5":
- version: 3.1.0
- resolution: "plist@npm:3.1.0"
- dependencies:
- "@xmldom/xmldom": ^0.8.8
- base64-js: ^1.5.1
- xmlbuilder: ^15.1.1
- checksum: c8ea013da8646d4c50dff82f9be39488054621cc229957621bb00add42b5d4ce3657cf58d4b10c50f7dea1a81118f825838f838baeb4e6f17fab453ecf91d424
- languageName: node
- linkType: hard
-
-"postcss@npm:^8.4.25":
- version: 8.4.26
- resolution: "postcss@npm:8.4.26"
- dependencies:
- nanoid: ^3.3.6
- picocolors: ^1.0.0
- source-map-js: ^1.0.2
- checksum: 1cf08ee10d58cbe98f94bf12ac49a5e5ed1588507d333d2642aacc24369ca987274e1f60ff4cbf0081f70d2ab18a5cd3a4a273f188d835b8e7f3ba381b184e57
- languageName: node
- linkType: hard
-
-"progress@npm:^2.0.3":
- version: 2.0.3
- resolution: "progress@npm:2.0.3"
- checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7
- languageName: node
- linkType: hard
-
-"promise-retry@npm:^2.0.1":
- version: 2.0.1
- resolution: "promise-retry@npm:2.0.1"
- dependencies:
- err-code: ^2.0.2
- retry: ^0.12.0
- checksum: f96a3f6d90b92b568a26f71e966cbbc0f63ab85ea6ff6c81284dc869b41510e6cdef99b6b65f9030f0db422bf7c96652a3fff9f2e8fb4a0f069d8f4430359429
- languageName: node
- linkType: hard
-
-"proxy-addr@npm:~2.0.7":
- version: 2.0.7
- resolution: "proxy-addr@npm:2.0.7"
- dependencies:
- forwarded: 0.2.0
- ipaddr.js: 1.9.1
- checksum: 29c6990ce9364648255454842f06f8c46fcd124d3e6d7c5066df44662de63cdc0bad032e9bf5a3d653ff72141cc7b6019873d685708ac8210c30458ad99f2b74
- languageName: node
- linkType: hard
-
-"pump@npm:^3.0.0":
- version: 3.0.0
- resolution: "pump@npm:3.0.0"
- dependencies:
- end-of-stream: ^1.1.0
- once: ^1.3.1
- checksum: e42e9229fba14732593a718b04cb5e1cfef8254544870997e0ecd9732b189a48e1256e4e5478148ecb47c8511dca2b09eae56b4d0aad8009e6fac8072923cfc9
- languageName: node
- linkType: hard
-
-"qs@npm:6.11.0":
- version: 6.11.0
- resolution: "qs@npm:6.11.0"
- dependencies:
- side-channel: ^1.0.4
- checksum: 6e1f29dd5385f7488ec74ac7b6c92f4d09a90408882d0c208414a34dd33badc1a621019d4c799a3df15ab9b1d0292f97c1dd71dc7c045e69f81a8064e5af7297
- languageName: node
- linkType: hard
-
-"queue-microtask@npm:^1.2.2":
- version: 1.2.3
- resolution: "queue-microtask@npm:1.2.3"
- checksum: b676f8c040cdc5b12723ad2f91414d267605b26419d5c821ff03befa817ddd10e238d22b25d604920340fd73efd8ba795465a0377c4adf45a4a41e4234e42dc4
- languageName: node
- linkType: hard
-
-"quick-lru@npm:^5.1.1":
- version: 5.1.1
- resolution: "quick-lru@npm:5.1.1"
- checksum: a516faa25574be7947969883e6068dbe4aa19e8ef8e8e0fd96cddd6d36485e9106d85c0041a27153286b0770b381328f4072aa40d3b18a19f5f7d2b78b94b5ed
- languageName: node
- linkType: hard
-
-"random-path@npm:^0.1.0":
- version: 0.1.2
- resolution: "random-path@npm:0.1.2"
- dependencies:
- base32-encode: ^0.1.0 || ^1.0.0
- murmur-32: ^0.1.0 || ^0.2.0
- checksum: 9fe83df7705e7c7707feba280433f1dd3937dfd6feccc85e1f5fad1e5f84930777a64faa871f4ced4c7825fdfeb5f727f70fc808d81914c02e4c914bac177a34
- languageName: node
- linkType: hard
-
-"range-parser@npm:~1.2.1":
- version: 1.2.1
- resolution: "range-parser@npm:1.2.1"
- checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9
- languageName: node
- linkType: hard
-
-"raw-body@npm:2.5.1":
- version: 2.5.1
- resolution: "raw-body@npm:2.5.1"
- dependencies:
- bytes: 3.1.2
- http-errors: 2.0.0
- iconv-lite: 0.4.24
- unpipe: 1.0.0
- checksum: 5362adff1575d691bb3f75998803a0ffed8c64eabeaa06e54b4ada25a0cd1b2ae7f4f5ec46565d1bec337e08b5ac90c76eaa0758de6f72a633f025d754dec29e
- languageName: node
- linkType: hard
-
-"rcedit@npm:^3.0.1":
- version: 3.0.1
- resolution: "rcedit@npm:3.0.1"
- dependencies:
- cross-spawn-windows-exe: ^1.1.0
- checksum: 73332443aa9e5c70bcd4e8a2f5195f5591a03ef08bf1fe477c116f2525e0d525ced0ad5c32c23dcadc27550aec297559e1f944676f833d25d549c7d27b95e165
- languageName: node
- linkType: hard
-
-"read-pkg-up@npm:^2.0.0":
- version: 2.0.0
- resolution: "read-pkg-up@npm:2.0.0"
- dependencies:
- find-up: ^2.0.0
- read-pkg: ^2.0.0
- checksum: 22f9026fb72219ecd165f94f589461c70a88461dc7ea0d439a310ef2a5271ff176a4df4e5edfad087d8ac89b8553945eb209476b671e8ed081c990f30fc40b27
- languageName: node
- linkType: hard
-
-"read-pkg@npm:^2.0.0":
- version: 2.0.0
- resolution: "read-pkg@npm:2.0.0"
- dependencies:
- load-json-file: ^2.0.0
- normalize-package-data: ^2.3.2
- path-type: ^2.0.0
- checksum: 85c5bf35f2d96acdd756151ba83251831bb2b1040b7d96adce70b2cb119b5320417f34876de0929f2d06c67f3df33ef4636427df3533913876f9ef2487a6f48f
- languageName: node
- linkType: hard
-
-"readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0":
- version: 3.6.2
- resolution: "readable-stream@npm:3.6.2"
- dependencies:
- inherits: ^2.0.3
- string_decoder: ^1.1.1
- util-deprecate: ^1.0.1
- checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d
- languageName: node
- linkType: hard
-
-"rechoir@npm:^0.8.0":
- version: 0.8.0
- resolution: "rechoir@npm:0.8.0"
- dependencies:
- resolve: ^1.20.0
- checksum: ad3caed8afdefbc33fbc30e6d22b86c35b3d51c2005546f4e79bcc03c074df804b3640ad18945e6bef9ed12caedc035655ec1082f64a5e94c849ff939dc0a788
- languageName: node
- linkType: hard
-
-"repeat-string@npm:^1.5.4":
- version: 1.6.1
- resolution: "repeat-string@npm:1.6.1"
- checksum: 1b809fc6db97decdc68f5b12c4d1a671c8e3f65ec4a40c238bc5200e44e85bcc52a54f78268ab9c29fcf5fe4f1343e805420056d1f30fa9a9ee4c2d93e3cc6c0
- languageName: node
- linkType: hard
-
-"require-directory@npm:^2.1.1":
- version: 2.1.1
- resolution: "require-directory@npm:2.1.1"
- checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80
- languageName: node
- linkType: hard
-
-"require-main-filename@npm:^2.0.0":
- version: 2.0.0
- resolution: "require-main-filename@npm:2.0.0"
- checksum: e9e294695fea08b076457e9ddff854e81bffbe248ed34c1eec348b7abbd22a0d02e8d75506559e2265e96978f3c4720bd77a6dad84755de8162b357eb6c778c7
- languageName: node
- linkType: hard
-
-"resolve-alpn@npm:^1.0.0":
- version: 1.2.1
- resolution: "resolve-alpn@npm:1.2.1"
- checksum: f558071fcb2c60b04054c99aebd572a2af97ef64128d59bef7ab73bd50d896a222a056de40ffc545b633d99b304c259ea9d0c06830d5c867c34f0bfa60b8eae0
- languageName: node
- linkType: hard
-
-"resolve-dir@npm:^1.0.0":
- version: 1.0.1
- resolution: "resolve-dir@npm:1.0.1"
- dependencies:
- expand-tilde: ^2.0.0
- global-modules: ^1.0.0
- checksum: ef736b8ed60d6645c3b573da17d329bfb50ec4e1d6c5ffd6df49e3497acef9226f9810ea6823b8ece1560e01dcb13f77a9f6180d4f242d00cc9a8f4de909c65c
- languageName: node
- linkType: hard
-
-"resolve-package@npm:^1.0.1":
- version: 1.0.1
- resolution: "resolve-package@npm:1.0.1"
- dependencies:
- get-installed-path: ^2.0.3
- checksum: ce89b69e58171ccbf5ea05afdcf42ae7ebd98e210472a2bee194750796d480d98703a773abb4dab1a685346ef91210c2aa6dbc5cfda1bdcd71b1b8cc43ef0627
- languageName: node
- linkType: hard
-
-"resolve@npm:^1.1.6, resolve@npm:^1.10.0, resolve@npm:^1.20.0":
- version: 1.22.3
- resolution: "resolve@npm:1.22.3"
- dependencies:
- is-core-module: ^2.12.0
- path-parse: ^1.0.7
- supports-preserve-symlinks-flag: ^1.0.0
- bin:
- resolve: bin/resolve
- checksum: fb834b81348428cb545ff1b828a72ea28feb5a97c026a1cf40aa1008352c72811ff4d4e71f2035273dc536dcfcae20c13604ba6283c612d70fa0b6e44519c374
- languageName: node
- linkType: hard
-
-"resolve@patch:resolve@^1.1.6#~builtin, resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.20.0#~builtin":
- version: 1.22.3
- resolution: "resolve@patch:resolve@npm%3A1.22.3#~builtin::version=1.22.3&hash=c3c19d"
- dependencies:
- is-core-module: ^2.12.0
- path-parse: ^1.0.7
- supports-preserve-symlinks-flag: ^1.0.0
- bin:
- resolve: bin/resolve
- checksum: ad59734723b596d0891321c951592ed9015a77ce84907f89c9d9307dd0c06e11a67906a3e628c4cae143d3e44898603478af0ddeb2bba3f229a9373efe342665
- languageName: node
- linkType: hard
-
-"responselike@npm:^2.0.0":
- version: 2.0.1
- resolution: "responselike@npm:2.0.1"
- dependencies:
- lowercase-keys: ^2.0.0
- checksum: b122535466e9c97b55e69c7f18e2be0ce3823c5d47ee8de0d9c0b114aa55741c6db8bfbfce3766a94d1272e61bfb1ebf0a15e9310ac5629fbb7446a861b4fd3a
- languageName: node
- linkType: hard
-
-"restore-cursor@npm:^3.1.0":
- version: 3.1.0
- resolution: "restore-cursor@npm:3.1.0"
- dependencies:
- onetime: ^5.1.0
- signal-exit: ^3.0.2
- checksum: f877dd8741796b909f2a82454ec111afb84eb45890eb49ac947d87991379406b3b83ff9673a46012fca0d7844bb989f45cc5b788254cf1a39b6b5a9659de0630
- languageName: node
- linkType: hard
-
-"retry@npm:^0.12.0":
- version: 0.12.0
- resolution: "retry@npm:0.12.0"
- checksum: 623bd7d2e5119467ba66202d733ec3c2e2e26568074923bc0585b6b99db14f357e79bdedb63cab56cec47491c4a0da7e6021a7465ca6dc4f481d3898fdd3158c
- languageName: node
- linkType: hard
-
-"reusify@npm:^1.0.4":
- version: 1.0.4
- resolution: "reusify@npm:1.0.4"
- checksum: c3076ebcc22a6bc252cb0b9c77561795256c22b757f40c0d8110b1300723f15ec0fc8685e8d4ea6d7666f36c79ccc793b1939c748bf36f18f542744a4e379fcc
- languageName: node
- linkType: hard
-
-"rfdc@npm:^1.3.0":
- version: 1.3.0
- resolution: "rfdc@npm:1.3.0"
- checksum: fb2ba8512e43519983b4c61bd3fa77c0f410eff6bae68b08614437bc3f35f91362215f7b4a73cbda6f67330b5746ce07db5dd9850ad3edc91271ad6deea0df32
- languageName: node
- linkType: hard
-
-"rimraf@npm:^3.0.0, rimraf@npm:^3.0.2":
- version: 3.0.2
- resolution: "rimraf@npm:3.0.2"
- dependencies:
- glob: ^7.1.3
- bin:
- rimraf: bin.js
- checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0
- languageName: node
- linkType: hard
-
-"rimraf@npm:~2.6.2":
- version: 2.6.3
- resolution: "rimraf@npm:2.6.3"
- dependencies:
- glob: ^7.1.3
- bin:
- rimraf: ./bin.js
- checksum: 3ea587b981a19016297edb96d1ffe48af7e6af69660e3b371dbfc73722a73a0b0e9be5c88089fbeeb866c389c1098e07f64929c7414290504b855f54f901ab10
- languageName: node
- linkType: hard
-
-"roarr@npm:^2.15.3":
- version: 2.15.4
- resolution: "roarr@npm:2.15.4"
- dependencies:
- boolean: ^3.0.1
- detect-node: ^2.0.4
- globalthis: ^1.0.1
- json-stringify-safe: ^5.0.1
- semver-compare: ^1.0.0
- sprintf-js: ^1.1.2
- checksum: 682e28d5491e3ae99728a35ba188f4f0ccb6347dbd492f95dc9f4bfdfe8ee63d8203ad234766ee2db88c8d7a300714304976eb095ce5c9366fe586c03a21586c
- languageName: node
- linkType: hard
-
-"rollup@npm:^3.25.2":
- version: 3.26.3
- resolution: "rollup@npm:3.26.3"
- dependencies:
- fsevents: ~2.3.2
- dependenciesMeta:
- fsevents:
- optional: true
- bin:
- rollup: dist/bin/rollup
- checksum: e6a765b2b7af709170344cc804392936613e06b6bdab46a04d264368d154bdadaaaf77de39e6e656bf728a060d7b4867d81e2464d791c0f37dd5b21aa9c7a6df
- languageName: node
- linkType: hard
-
-"run-parallel@npm:^1.1.9":
- version: 1.2.0
- resolution: "run-parallel@npm:1.2.0"
- dependencies:
- queue-microtask: ^1.2.2
- checksum: cb4f97ad25a75ebc11a8ef4e33bb962f8af8516bb2001082ceabd8902e15b98f4b84b4f8a9b222e5d57fc3bd1379c483886ed4619367a7680dad65316993021d
- languageName: node
- linkType: hard
-
-"rxjs@npm:^7.8.0":
- version: 7.8.1
- resolution: "rxjs@npm:7.8.1"
- dependencies:
- tslib: ^2.1.0
- checksum: de4b53db1063e618ec2eca0f7965d9137cabe98cf6be9272efe6c86b47c17b987383df8574861bcced18ebd590764125a901d5506082be84a8b8e364bf05f119
- languageName: node
- linkType: hard
-
-"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0":
- version: 5.2.1
- resolution: "safe-buffer@npm:5.2.1"
- checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491
- languageName: node
- linkType: hard
-
-"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0":
- version: 2.1.2
- resolution: "safer-buffer@npm:2.1.2"
- checksum: cab8f25ae6f1434abee8d80023d7e72b598cf1327164ddab31003c51215526801e40b66c5e65d658a0af1e9d6478cadcb4c745f4bd6751f97d8644786c0978b0
- languageName: node
- linkType: hard
-
-"semver-compare@npm:^1.0.0":
- version: 1.0.0
- resolution: "semver-compare@npm:1.0.0"
- checksum: dd1d7e2909744cf2cf71864ac718efc990297f9de2913b68e41a214319e70174b1d1793ac16e31183b128c2b9812541300cb324db8168e6cf6b570703b171c68
- languageName: node
- linkType: hard
-
-"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.5.0":
- version: 5.7.2
- resolution: "semver@npm:5.7.2"
- bin:
- semver: bin/semver
- checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686
- languageName: node
- linkType: hard
-
-"semver@npm:^6.2.0":
- version: 6.3.1
- resolution: "semver@npm:6.3.1"
- bin:
- semver: bin/semver.js
- checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2
- languageName: node
- linkType: hard
-
-"semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5":
- version: 7.5.4
- resolution: "semver@npm:7.5.4"
- dependencies:
- lru-cache: ^6.0.0
- bin:
- semver: bin/semver.js
- checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3
- languageName: node
- linkType: hard
-
-"send@npm:0.18.0":
- version: 0.18.0
- resolution: "send@npm:0.18.0"
- dependencies:
- debug: 2.6.9
- depd: 2.0.0
- destroy: 1.2.0
- encodeurl: ~1.0.2
- escape-html: ~1.0.3
- etag: ~1.8.1
- fresh: 0.5.2
- http-errors: 2.0.0
- mime: 1.6.0
- ms: 2.1.3
- on-finished: 2.4.1
- range-parser: ~1.2.1
- statuses: 2.0.1
- checksum: 74fc07ebb58566b87b078ec63e5a3e41ecd987e4272ba67b7467e86c6ad51bc6b0b0154133b6d8b08a2ddda360464f71382f7ef864700f34844a76c8027817a8
- languageName: node
- linkType: hard
-
-"serialize-error@npm:^7.0.1":
- version: 7.0.1
- resolution: "serialize-error@npm:7.0.1"
- dependencies:
- type-fest: ^0.13.1
- checksum: e0aba4dca2fc9fe74ae1baf38dbd99190e1945445a241ba646290f2176cdb2032281a76443b02ccf0caf30da5657d510746506368889a593b9835a497fc0732e
- languageName: node
- linkType: hard
-
-"serve-static@npm:1.15.0":
- version: 1.15.0
- resolution: "serve-static@npm:1.15.0"
- dependencies:
- encodeurl: ~1.0.2
- escape-html: ~1.0.3
- parseurl: ~1.3.3
- send: 0.18.0
- checksum: af57fc13be40d90a12562e98c0b7855cf6e8bd4c107fe9a45c212bf023058d54a1871b1c89511c3958f70626fff47faeb795f5d83f8cf88514dbaeb2b724464d
- languageName: node
- linkType: hard
-
-"set-blocking@npm:^2.0.0":
- version: 2.0.0
- resolution: "set-blocking@npm:2.0.0"
- checksum: 6e65a05f7cf7ebdf8b7c75b101e18c0b7e3dff4940d480efed8aad3a36a4005140b660fa1d804cb8bce911cac290441dc728084a30504d3516ac2ff7ad607b02
- languageName: node
- linkType: hard
-
-"setprototypeof@npm:1.2.0":
- version: 1.2.0
- resolution: "setprototypeof@npm:1.2.0"
- checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89
- languageName: node
- linkType: hard
-
-"shebang-command@npm:^1.2.0":
- version: 1.2.0
- resolution: "shebang-command@npm:1.2.0"
- dependencies:
- shebang-regex: ^1.0.0
- checksum: 9eed1750301e622961ba5d588af2212505e96770ec376a37ab678f965795e995ade7ed44910f5d3d3cb5e10165a1847f52d3348c64e146b8be922f7707958908
- languageName: node
- linkType: hard
-
-"shebang-command@npm:^2.0.0":
- version: 2.0.0
- resolution: "shebang-command@npm:2.0.0"
- dependencies:
- shebang-regex: ^3.0.0
- checksum: 6b52fe87271c12968f6a054e60f6bde5f0f3d2db483a1e5c3e12d657c488a15474121a1d55cd958f6df026a54374ec38a4a963988c213b7570e1d51575cea7fa
- languageName: node
- linkType: hard
-
-"shebang-regex@npm:^1.0.0":
- version: 1.0.0
- resolution: "shebang-regex@npm:1.0.0"
- checksum: 404c5a752cd40f94591dfd9346da40a735a05139dac890ffc229afba610854d8799aaa52f87f7e0c94c5007f2c6af55bdcaeb584b56691926c5eaf41dc8f1372
- languageName: node
- linkType: hard
-
-"shebang-regex@npm:^3.0.0":
- version: 3.0.0
- resolution: "shebang-regex@npm:3.0.0"
- checksum: 1a2bcae50de99034fcd92ad4212d8e01eedf52c7ec7830eedcf886622804fe36884278f2be8be0ea5fde3fd1c23911643a4e0f726c8685b61871c8908af01222
- languageName: node
- linkType: hard
-
-"side-channel@npm:^1.0.4":
- version: 1.0.4
- resolution: "side-channel@npm:1.0.4"
- dependencies:
- call-bind: ^1.0.0
- get-intrinsic: ^1.0.2
- object-inspect: ^1.9.0
- checksum: 351e41b947079c10bd0858364f32bb3a7379514c399edb64ab3dce683933483fc63fb5e4efe0a15a2e8a7e3c436b6a91736ddb8d8c6591b0460a24bb4a1ee245
- languageName: node
- linkType: hard
-
-"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7":
- version: 3.0.7
- resolution: "signal-exit@npm:3.0.7"
- checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318
- languageName: node
- linkType: hard
-
-"signal-exit@npm:^4.0.1":
- version: 4.0.2
- resolution: "signal-exit@npm:4.0.2"
- checksum: 41f5928431cc6e91087bf0343db786a6313dd7c6fd7e551dbc141c95bb5fb26663444fd9df8ea47c5d7fc202f60aa7468c3162a9365cbb0615fc5e1b1328fe31
- languageName: node
- linkType: hard
-
-"slice-ansi@npm:^3.0.0":
- version: 3.0.0
- resolution: "slice-ansi@npm:3.0.0"
- dependencies:
- ansi-styles: ^4.0.0
- astral-regex: ^2.0.0
- is-fullwidth-code-point: ^3.0.0
- checksum: 5ec6d022d12e016347e9e3e98a7eb2a592213a43a65f1b61b74d2c78288da0aded781f665807a9f3876b9daa9ad94f64f77d7633a0458876c3a4fdc4eb223f24
- languageName: node
- linkType: hard
-
-"slice-ansi@npm:^4.0.0":
- version: 4.0.0
- resolution: "slice-ansi@npm:4.0.0"
- dependencies:
- ansi-styles: ^4.0.0
- astral-regex: ^2.0.0
- is-fullwidth-code-point: ^3.0.0
- checksum: 4a82d7f085b0e1b070e004941ada3c40d3818563ac44766cca4ceadd2080427d337554f9f99a13aaeb3b4a94d9964d9466c807b3d7b7541d1ec37ee32d308756
- languageName: node
- linkType: hard
-
-"smart-buffer@npm:^4.2.0":
- version: 4.2.0
- resolution: "smart-buffer@npm:4.2.0"
- checksum: b5167a7142c1da704c0e3af85c402002b597081dd9575031a90b4f229ca5678e9a36e8a374f1814c8156a725d17008ae3bde63b92f9cfd132526379e580bec8b
- languageName: node
- linkType: hard
-
-"socks-proxy-agent@npm:^7.0.0":
- version: 7.0.0
- resolution: "socks-proxy-agent@npm:7.0.0"
- dependencies:
- agent-base: ^6.0.2
- debug: ^4.3.3
- socks: ^2.6.2
- checksum: 720554370154cbc979e2e9ce6a6ec6ced205d02757d8f5d93fe95adae454fc187a5cbfc6b022afab850a5ce9b4c7d73e0f98e381879cf45f66317a4895953846
- languageName: node
- linkType: hard
-
-"socks@npm:^2.6.2":
- version: 2.7.1
- resolution: "socks@npm:2.7.1"
- dependencies:
- ip: ^2.0.0
- smart-buffer: ^4.2.0
- checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748
- languageName: node
- linkType: hard
-
-"source-map-js@npm:^1.0.2":
- version: 1.0.2
- resolution: "source-map-js@npm:1.0.2"
- checksum: c049a7fc4deb9a7e9b481ae3d424cc793cb4845daa690bc5a05d428bf41bf231ced49b4cf0c9e77f9d42fdb3d20d6187619fc586605f5eabe995a316da8d377c
- languageName: node
- linkType: hard
-
-"source-map-support@npm:^0.5.13":
- version: 0.5.21
- resolution: "source-map-support@npm:0.5.21"
- dependencies:
- buffer-from: ^1.0.0
- source-map: ^0.6.0
- checksum: 43e98d700d79af1d36f859bdb7318e601dfc918c7ba2e98456118ebc4c4872b327773e5a1df09b0524e9e5063bb18f0934538eace60cca2710d1fa687645d137
- languageName: node
- linkType: hard
-
-"source-map@npm:^0.6.0":
- version: 0.6.1
- resolution: "source-map@npm:0.6.1"
- checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2
- languageName: node
- linkType: hard
-
-"spdx-correct@npm:^3.0.0":
- version: 3.2.0
- resolution: "spdx-correct@npm:3.2.0"
- dependencies:
- spdx-expression-parse: ^3.0.0
- spdx-license-ids: ^3.0.0
- checksum: e9ae98d22f69c88e7aff5b8778dc01c361ef635580e82d29e5c60a6533cc8f4d820803e67d7432581af0cc4fb49973125076ee3b90df191d153e223c004193b2
- languageName: node
- linkType: hard
-
-"spdx-exceptions@npm:^2.1.0":
- version: 2.3.0
- resolution: "spdx-exceptions@npm:2.3.0"
- checksum: cb69a26fa3b46305637123cd37c85f75610e8c477b6476fa7354eb67c08128d159f1d36715f19be6f9daf4b680337deb8c65acdcae7f2608ba51931540687ac0
- languageName: node
- linkType: hard
-
-"spdx-expression-parse@npm:^3.0.0":
- version: 3.0.1
- resolution: "spdx-expression-parse@npm:3.0.1"
- dependencies:
- spdx-exceptions: ^2.1.0
- spdx-license-ids: ^3.0.0
- checksum: a1c6e104a2cbada7a593eaa9f430bd5e148ef5290d4c0409899855ce8b1c39652bcc88a725259491a82601159d6dc790bedefc9016c7472f7de8de7361f8ccde
- languageName: node
- linkType: hard
-
-"spdx-license-ids@npm:^3.0.0":
- version: 3.0.13
- resolution: "spdx-license-ids@npm:3.0.13"
- checksum: 3469d85c65f3245a279fa11afc250c3dca96e9e847f2f79d57f466940c5bb8495da08a542646086d499b7f24a74b8d0b42f3fc0f95d50ff99af1f599f6360ad7
- languageName: node
- linkType: hard
-
-"sprintf-js@npm:^1.1.2":
- version: 1.1.2
- resolution: "sprintf-js@npm:1.1.2"
- checksum: d4bb46464632b335e5faed381bd331157e0af64915a98ede833452663bc672823db49d7531c32d58798e85236581fb7342fd0270531ffc8f914e186187bf1c90
- languageName: node
- linkType: hard
-
-"ssri@npm:^10.0.0":
- version: 10.0.4
- resolution: "ssri@npm:10.0.4"
- dependencies:
- minipass: ^5.0.0
- checksum: fb14da9f8a72b04eab163eb13a9dda11d5962cd2317f85457c4e0b575e9a6e0e3a6a87b5bf122c75cb36565830cd5f263fb457571bf6f1587eb5f95d095d6165
- languageName: node
- linkType: hard
-
-"statuses@npm:2.0.1":
- version: 2.0.1
- resolution: "statuses@npm:2.0.1"
- checksum: 18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb
- languageName: node
- linkType: hard
-
-"stream-buffers@npm:~2.2.0":
- version: 2.2.0
- resolution: "stream-buffers@npm:2.2.0"
- checksum: 4587d9e8f050d689fb38b4295e73408401b16de8edecc12026c6f4ae92956705ecfd995ae3845d7fa3ebf19502d5754df9143d91447fd881d86e518f43882c1c
- languageName: node
- linkType: hard
-
-"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3":
- version: 4.2.3
- resolution: "string-width@npm:4.2.3"
- dependencies:
- emoji-regex: ^8.0.0
- is-fullwidth-code-point: ^3.0.0
- strip-ansi: ^6.0.1
- checksum: e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb
- languageName: node
- linkType: hard
-
-"string-width@npm:^5.0.1, string-width@npm:^5.1.2":
- version: 5.1.2
- resolution: "string-width@npm:5.1.2"
- dependencies:
- eastasianwidth: ^0.2.0
- emoji-regex: ^9.2.2
- strip-ansi: ^7.0.1
- checksum: 7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193
- languageName: node
- linkType: hard
-
-"string_decoder@npm:^1.1.1":
- version: 1.3.0
- resolution: "string_decoder@npm:1.3.0"
- dependencies:
- safe-buffer: ~5.2.0
- checksum: 8417646695a66e73aefc4420eb3b84cc9ffd89572861fe004e6aeb13c7bc00e2f616247505d2dbbef24247c372f70268f594af7126f43548565c68c117bdeb56
- languageName: node
- linkType: hard
-
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1":
- version: 6.0.1
- resolution: "strip-ansi@npm:6.0.1"
- dependencies:
- ansi-regex: ^5.0.1
- checksum: f3cd25890aef3ba6e1a74e20896c21a46f482e93df4a06567cebf2b57edabb15133f1f94e57434e0a958d61186087b1008e89c94875d019910a213181a14fc8c
- languageName: node
- linkType: hard
-
-"strip-ansi@npm:^7.0.1":
- version: 7.1.0
- resolution: "strip-ansi@npm:7.1.0"
- dependencies:
- ansi-regex: ^6.0.1
- checksum: 859c73fcf27869c22a4e4d8c6acfe690064659e84bef9458aa6d13719d09ca88dcfd40cbf31fd0be63518ea1a643fe070b4827d353e09533a5b0b9fd4553d64d
- languageName: node
- linkType: hard
-
-"strip-bom@npm:^3.0.0":
- version: 3.0.0
- resolution: "strip-bom@npm:3.0.0"
- checksum: 8d50ff27b7ebe5ecc78f1fe1e00fcdff7af014e73cf724b46fb81ef889eeb1015fc5184b64e81a2efe002180f3ba431bdd77e300da5c6685d702780fbf0c8d5b
- languageName: node
- linkType: hard
-
-"strip-eof@npm:^1.0.0":
- version: 1.0.0
- resolution: "strip-eof@npm:1.0.0"
- checksum: 40bc8ddd7e072f8ba0c2d6d05267b4e0a4800898c3435b5fb5f5a21e6e47dfaff18467e7aa0d1844bb5d6274c3097246595841fbfeb317e541974ee992cac506
- languageName: node
- linkType: hard
-
-"strip-outer@npm:^1.0.1":
- version: 1.0.1
- resolution: "strip-outer@npm:1.0.1"
- dependencies:
- escape-string-regexp: ^1.0.2
- checksum: f8d65d33ca2b49aabc66bb41d689dda7b8b9959d320e3a40a2ef4d7079ff2f67ffb72db43f179f48dbf9495c2e33742863feab7a584d180fa62505439162c191
- languageName: node
- linkType: hard
-
-"sudo-prompt@npm:^9.1.1":
- version: 9.2.1
- resolution: "sudo-prompt@npm:9.2.1"
- checksum: 50a29eec2f264f2b78d891452a64112d839a30bffbff4ec065dba4af691a35b23cdb8f9107d413e25c1a9f1925644a19994c00602495cab033d53f585fdfd665
- languageName: node
- linkType: hard
-
-"sumchecker@npm:^3.0.1":
- version: 3.0.1
- resolution: "sumchecker@npm:3.0.1"
- dependencies:
- debug: ^4.1.0
- checksum: 31ba7a62c889236b5b07f75b5c250d481158a1ca061b8f234fca0457bdbe48a20e5011c12c715343dc577e111463dc3d9e721b98015a445a2a88c35e0c9f0f91
- languageName: node
- linkType: hard
-
-"supports-color@npm:^7.1.0":
- version: 7.2.0
- resolution: "supports-color@npm:7.2.0"
- dependencies:
- has-flag: ^4.0.0
- checksum: 3dda818de06ebbe5b9653e07842d9479f3555ebc77e9a0280caf5a14fb877ffee9ed57007c3b78f5a6324b8dbeec648d9e97a24e2ed9fdb81ddc69ea07100f4a
- languageName: node
- linkType: hard
-
-"supports-preserve-symlinks-flag@npm:^1.0.0":
- version: 1.0.0
- resolution: "supports-preserve-symlinks-flag@npm:1.0.0"
- checksum: 53b1e247e68e05db7b3808b99b892bd36fb096e6fba213a06da7fab22045e97597db425c724f2bbd6c99a3c295e1e73f3e4de78592289f38431049e1277ca0ae
- languageName: node
- linkType: hard
-
-"tar@npm:^6.0.5, tar@npm:^6.1.11, tar@npm:^6.1.2":
- version: 6.1.15
- resolution: "tar@npm:6.1.15"
- dependencies:
- chownr: ^2.0.0
- fs-minipass: ^2.0.0
- minipass: ^5.0.0
- minizlib: ^2.1.1
- mkdirp: ^1.0.3
- yallist: ^4.0.0
- checksum: f23832fceeba7578bf31907aac744ae21e74a66f4a17a9e94507acf460e48f6db598c7023882db33bab75b80e027c21f276d405e4a0322d58f51c7088d428268
- languageName: node
- linkType: hard
-
-"temp@npm:^0.9.0":
- version: 0.9.4
- resolution: "temp@npm:0.9.4"
- dependencies:
- mkdirp: ^0.5.1
- rimraf: ~2.6.2
- checksum: 8709d4d63278bd309ca0e49e80a268308dea543a949e71acd427b3314cd9417da9a2cc73425dd9c21c6780334dbffd67e05e7be5aaa73e9affe8479afc6f20e3
- languageName: node
- linkType: hard
-
-"through@npm:^2.3.8":
- version: 2.3.8
- resolution: "through@npm:2.3.8"
- checksum: a38c3e059853c494af95d50c072b83f8b676a9ba2818dcc5b108ef252230735c54e0185437618596c790bbba8fcdaef5b290405981ffa09dce67b1f1bf190cbd
- languageName: node
- linkType: hard
-
-"tiny-each-async@npm:2.0.3":
- version: 2.0.3
- resolution: "tiny-each-async@npm:2.0.3"
- checksum: 363511e6dd1dd9eadee4809d8a3485783f24579ae464c7b0768bb48047e6eaae3360cfe72b2ba345523d1d4033b5542129771c320bfb756abcf4918824511624
- languageName: node
- linkType: hard
-
-"tmp-promise@npm:^3.0.2":
- version: 3.0.3
- resolution: "tmp-promise@npm:3.0.3"
- dependencies:
- tmp: ^0.2.0
- checksum: f854f5307dcee6455927ec3da9398f139897faf715c5c6dcee6d9471ae85136983ea06662eba2edf2533bdcb0fca66d16648e79e14381e30c7fb20be9c1aa62c
- languageName: node
- linkType: hard
-
-"tmp@npm:^0.2.0":
- version: 0.2.1
- resolution: "tmp@npm:0.2.1"
- dependencies:
- rimraf: ^3.0.0
- checksum: 8b1214654182575124498c87ca986ac53dc76ff36e8f0e0b67139a8d221eaecfdec108c0e6ec54d76f49f1f72ab9325500b246f562b926f85bcdfca8bf35df9e
- languageName: node
- linkType: hard
-
-"tn1150@npm:^0.1.0":
- version: 0.1.0
- resolution: "tn1150@npm:0.1.0"
- dependencies:
- unorm: ^1.4.1
- checksum: 525b996bd02aacb77db951c6cedc59262fc737749a9a26b6ec2c120426196f92fe796ba161382499401f9ffc2652455a21467e8d8142cb352a5017c3f1292e97
- languageName: node
- linkType: hard
-
-"to-data-view@npm:^1.1.0":
- version: 1.1.0
- resolution: "to-data-view@npm:1.1.0"
- checksum: 53bf818cf7ed4b481568085cfed5528b268efe1e95d0b90c2a45031de9cf40de91600771c046924348fdedbedb54f655f98e7bf1c51041ba06f0ec3f2fd53dc6
- languageName: node
- linkType: hard
-
-"to-regex-range@npm:^5.0.1":
- version: 5.0.1
- resolution: "to-regex-range@npm:5.0.1"
- dependencies:
- is-number: ^7.0.0
- checksum: f76fa01b3d5be85db6a2a143e24df9f60dd047d151062d0ba3df62953f2f697b16fe5dad9b0ac6191c7efc7b1d9dcaa4b768174b7b29da89d4428e64bc0a20ed
- languageName: node
- linkType: hard
-
-"toidentifier@npm:1.0.1":
- version: 1.0.1
- resolution: "toidentifier@npm:1.0.1"
- checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45
- languageName: node
- linkType: hard
-
-"tr46@npm:~0.0.3":
- version: 0.0.3
- resolution: "tr46@npm:0.0.3"
- checksum: 726321c5eaf41b5002e17ffbd1fb7245999a073e8979085dacd47c4b4e8068ff5777142fc6726d6ca1fd2ff16921b48788b87225cbc57c72636f6efa8efbffe3
- languageName: node
- linkType: hard
-
-"trim-repeated@npm:^1.0.0":
- version: 1.0.0
- resolution: "trim-repeated@npm:1.0.0"
- dependencies:
- escape-string-regexp: ^1.0.2
- checksum: e25c235305b82c43f1d64a67a71226c406b00281755e4c2c4f3b1d0b09c687a535dd3c4483327f949f28bb89dc400a0bc5e5b749054f4b99f49ebfe48ba36496
- languageName: node
- linkType: hard
-
-"tslib@npm:^2.1.0":
- version: 2.6.0
- resolution: "tslib@npm:2.6.0"
- checksum: c01066038f950016a18106ddeca4649b4d76caa76ec5a31e2a26e10586a59fceb4ee45e96719bf6c715648e7c14085a81fee5c62f7e9ebee68e77a5396e5538f
- languageName: node
- linkType: hard
-
-"type-fest@npm:^0.13.1":
- version: 0.13.1
- resolution: "type-fest@npm:0.13.1"
- checksum: e6bf2e3c449f27d4ef5d56faf8b86feafbc3aec3025fc9a5fbe2db0a2587c44714521f9c30d8516a833c8c506d6263f5cc11267522b10c6ccdb6cc55b0a9d1c4
- languageName: node
- linkType: hard
-
-"type-fest@npm:^0.21.3":
- version: 0.21.3
- resolution: "type-fest@npm:0.21.3"
- checksum: e6b32a3b3877f04339bae01c193b273c62ba7bfc9e325b8703c4ee1b32dc8fe4ef5dfa54bf78265e069f7667d058e360ae0f37be5af9f153b22382cd55a9afe0
- languageName: node
- linkType: hard
-
-"type-is@npm:~1.6.18":
- version: 1.6.18
- resolution: "type-is@npm:1.6.18"
- dependencies:
- media-typer: 0.3.0
- mime-types: ~2.1.24
- checksum: 2c8e47675d55f8b4e404bcf529abdf5036c537a04c2b20177bcf78c9e3c1da69da3942b1346e6edb09e823228c0ee656ef0e033765ec39a70d496ef601a0c657
- languageName: node
- linkType: hard
-
-"unique-filename@npm:^3.0.0":
- version: 3.0.0
- resolution: "unique-filename@npm:3.0.0"
- dependencies:
- unique-slug: ^4.0.0
- checksum: 8e2f59b356cb2e54aab14ff98a51ac6c45781d15ceaab6d4f1c2228b780193dc70fae4463ce9e1df4479cb9d3304d7c2043a3fb905bdeca71cc7e8ce27e063df
- languageName: node
- linkType: hard
-
-"unique-slug@npm:^4.0.0":
- version: 4.0.0
- resolution: "unique-slug@npm:4.0.0"
- dependencies:
- imurmurhash: ^0.1.4
- checksum: 0884b58365af59f89739e6f71e3feacb5b1b41f2df2d842d0757933620e6de08eff347d27e9d499b43c40476cbaf7988638d3acb2ffbcb9d35fd035591adfd15
- languageName: node
- linkType: hard
-
-"universalify@npm:^0.1.0":
- version: 0.1.2
- resolution: "universalify@npm:0.1.2"
- checksum: 40cdc60f6e61070fe658ca36016a8f4ec216b29bf04a55dce14e3710cc84c7448538ef4dad3728d0bfe29975ccd7bfb5f414c45e7b78883567fb31b246f02dff
- languageName: node
- linkType: hard
-
-"universalify@npm:^2.0.0":
- version: 2.0.0
- resolution: "universalify@npm:2.0.0"
- checksum: 2406a4edf4a8830aa6813278bab1f953a8e40f2f63a37873ffa9a3bc8f9745d06cc8e88f3572cb899b7e509013f7f6fcc3e37e8a6d914167a5381d8440518c44
- languageName: node
- linkType: hard
-
-"unorm@npm:^1.4.1":
- version: 1.6.0
- resolution: "unorm@npm:1.6.0"
- checksum: 9a86546256a45f855b6cfe719086785d6aada94f63778cecdecece8d814ac26af76cb6da70130da0a08b8803bbf0986e56c7ec4249038198f3de02607fffd811
- languageName: node
- linkType: hard
-
-"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
- version: 1.0.0
- resolution: "unpipe@npm:1.0.0"
- checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2
- languageName: node
- linkType: hard
-
-"username@npm:^5.1.0":
- version: 5.1.0
- resolution: "username@npm:5.1.0"
- dependencies:
- execa: ^1.0.0
- mem: ^4.3.0
- checksum: 455c3b2103c164c867c263696fa3bc9a4066a3941d2d5d04bb51d9e092874af075c08311d50c9fc4685d75b3dcad43dd42d3ac1a775340f473042797dce86edb
- languageName: node
- linkType: hard
-
-"util-deprecate@npm:^1.0.1":
- version: 1.0.2
- resolution: "util-deprecate@npm:1.0.2"
- checksum: 474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2
- languageName: node
- linkType: hard
-
-"utils-merge@npm:1.0.1":
- version: 1.0.1
- resolution: "utils-merge@npm:1.0.1"
- checksum: c81095493225ecfc28add49c106ca4f09cdf56bc66731aa8dabc2edbbccb1e1bfe2de6a115e5c6a380d3ea166d1636410b62ef216bb07b3feb1cfde1d95d5080
- languageName: node
- linkType: hard
-
-"validate-npm-package-license@npm:^3.0.1":
- version: 3.0.4
- resolution: "validate-npm-package-license@npm:3.0.4"
- dependencies:
- spdx-correct: ^3.0.0
- spdx-expression-parse: ^3.0.0
- checksum: 35703ac889d419cf2aceef63daeadbe4e77227c39ab6287eeb6c1b36a746b364f50ba22e88591f5d017bc54685d8137bc2d328d0a896e4d3fd22093c0f32a9ad
- languageName: node
- linkType: hard
-
-"vary@npm:~1.1.2":
- version: 1.1.2
- resolution: "vary@npm:1.1.2"
- checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b
- languageName: node
- linkType: hard
-
-"vite@npm:^4.1.1":
- version: 4.4.4
- resolution: "vite@npm:4.4.4"
- dependencies:
- esbuild: ^0.18.10
- fsevents: ~2.3.2
- postcss: ^8.4.25
- rollup: ^3.25.2
- peerDependencies:
- "@types/node": ">= 14"
- less: "*"
- lightningcss: ^1.21.0
- sass: "*"
- stylus: "*"
- sugarss: "*"
- terser: ^5.4.0
- dependenciesMeta:
- fsevents:
- optional: true
- peerDependenciesMeta:
- "@types/node":
- optional: true
- less:
- optional: true
- lightningcss:
- optional: true
- sass:
- optional: true
- stylus:
- optional: true
- sugarss:
- optional: true
- terser:
- optional: true
- bin:
- vite: bin/vite.js
- checksum: 51c208e53680fa46f7166e49b037625ae43d507f85f1fd3da7e290263bccb77d5f8c466fe82746285927620afeeff949ac3b8e1b6a7b4fe7bfe11419729256b4
- languageName: node
- linkType: hard
-
-"wcwidth@npm:^1.0.1":
- version: 1.0.1
- resolution: "wcwidth@npm:1.0.1"
- dependencies:
- defaults: ^1.0.3
- checksum: 814e9d1ddcc9798f7377ffa448a5a3892232b9275ebb30a41b529607691c0491de47cba426e917a4d08ded3ee7e9ba2f3fe32e62ee3cd9c7d3bafb7754bd553c
- languageName: node
- linkType: hard
-
-"webidl-conversions@npm:^3.0.0":
- version: 3.0.1
- resolution: "webidl-conversions@npm:3.0.1"
- checksum: c92a0a6ab95314bde9c32e1d0a6dfac83b578f8fa5f21e675bc2706ed6981bc26b7eb7e6a1fab158e5ce4adf9caa4a0aee49a52505d4d13c7be545f15021b17c
- languageName: node
- linkType: hard
-
-"whatwg-url@npm:^5.0.0":
- version: 5.0.0
- resolution: "whatwg-url@npm:5.0.0"
- dependencies:
- tr46: ~0.0.3
- webidl-conversions: ^3.0.0
- checksum: b8daed4ad3356cc4899048a15b2c143a9aed0dfae1f611ebd55073310c7b910f522ad75d727346ad64203d7e6c79ef25eafd465f4d12775ca44b90fa82ed9e2c
- languageName: node
- linkType: hard
-
-"which-module@npm:^2.0.0":
- version: 2.0.1
- resolution: "which-module@npm:2.0.1"
- checksum: 1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be
- languageName: node
- linkType: hard
-
-"which@npm:^1.2.14, which@npm:^1.2.9":
- version: 1.3.1
- resolution: "which@npm:1.3.1"
- dependencies:
- isexe: ^2.0.0
- bin:
- which: ./bin/which
- checksum: f2e185c6242244b8426c9df1510e86629192d93c1a986a7d2a591f2c24869e7ffd03d6dac07ca863b2e4c06f59a4cc9916c585b72ee9fa1aa609d0124df15e04
- languageName: node
- linkType: hard
-
-"which@npm:^2.0.1, which@npm:^2.0.2":
- version: 2.0.2
- resolution: "which@npm:2.0.2"
- dependencies:
- isexe: ^2.0.0
- bin:
- node-which: ./bin/node-which
- checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1
- languageName: node
- linkType: hard
-
-"wide-align@npm:^1.1.5":
- version: 1.1.5
- resolution: "wide-align@npm:1.1.5"
- dependencies:
- string-width: ^1.0.2 || 2 || 3 || 4
- checksum: d5fc37cd561f9daee3c80e03b92ed3e84d80dde3365a8767263d03dacfc8fa06b065ffe1df00d8c2a09f731482fcacae745abfbb478d4af36d0a891fad4834d3
- languageName: node
- linkType: hard
-
-"word-wrap@npm:^1.2.3":
- version: 1.2.4
- resolution: "word-wrap@npm:1.2.4"
- checksum: 8f1f2e0a397c0e074ca225ba9f67baa23f99293bc064e31355d426ae91b8b3f6b5f6c1fc9ae5e9141178bb362d563f55e62fd8d5c31f2a77e3ade56cb3e35bd1
- languageName: node
- linkType: hard
-
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0":
- version: 7.0.0
- resolution: "wrap-ansi@npm:7.0.0"
- dependencies:
- ansi-styles: ^4.0.0
- string-width: ^4.1.0
- strip-ansi: ^6.0.0
- checksum: a790b846fd4505de962ba728a21aaeda189b8ee1c7568ca5e817d85930e06ef8d1689d49dbf0e881e8ef84436af3a88bc49115c2e2788d841ff1b8b5b51a608b
- languageName: node
- linkType: hard
-
-"wrap-ansi@npm:^6.2.0":
- version: 6.2.0
- resolution: "wrap-ansi@npm:6.2.0"
- dependencies:
- ansi-styles: ^4.0.0
- string-width: ^4.1.0
- strip-ansi: ^6.0.0
- checksum: 6cd96a410161ff617b63581a08376f0cb9162375adeb7956e10c8cd397821f7eb2a6de24eb22a0b28401300bf228c86e50617cd568209b5f6775b93c97d2fe3a
- languageName: node
- linkType: hard
-
-"wrap-ansi@npm:^8.1.0":
- version: 8.1.0
- resolution: "wrap-ansi@npm:8.1.0"
- dependencies:
- ansi-styles: ^6.1.0
- string-width: ^5.0.1
- strip-ansi: ^7.0.1
- checksum: 371733296dc2d616900ce15a0049dca0ef67597d6394c57347ba334393599e800bab03c41d4d45221b6bc967b8c453ec3ae4749eff3894202d16800fdfe0e238
- languageName: node
- linkType: hard
-
-"wrappy@npm:1":
- version: 1.0.2
- resolution: "wrappy@npm:1.0.2"
- checksum: 159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5
- languageName: node
- linkType: hard
-
-"ws@npm:^7.4.6":
- version: 7.5.9
- resolution: "ws@npm:7.5.9"
- peerDependencies:
- bufferutil: ^4.0.1
- utf-8-validate: ^5.0.2
- peerDependenciesMeta:
- bufferutil:
- optional: true
- utf-8-validate:
- optional: true
- checksum: c3c100a181b731f40b7f2fddf004aa023f79d64f489706a28bc23ff88e87f6a64b3c6651fbec3a84a53960b75159574d7a7385709847a62ddb7ad6af76f49138
- languageName: node
- linkType: hard
-
-"xmlbuilder@npm:^15.1.1":
- version: 15.1.1
- resolution: "xmlbuilder@npm:15.1.1"
- checksum: 14f7302402e28d1f32823583d121594a9dca36408d40320b33f598bd589ca5163a352d076489c9c64d2dc1da19a790926a07bf4191275330d4de2b0d85bb1843
- languageName: node
- linkType: hard
-
-"xtend@npm:^4.0.0":
- version: 4.0.2
- resolution: "xtend@npm:4.0.2"
- checksum: ac5dfa738b21f6e7f0dd6e65e1b3155036d68104e67e5d5d1bde74892e327d7e5636a076f625599dc394330a731861e87343ff184b0047fef1360a7ec0a5a36a
- languageName: node
- linkType: hard
-
-"xterm-addon-fit@npm:^0.5.0":
- version: 0.5.0
- resolution: "xterm-addon-fit@npm:0.5.0"
- peerDependencies:
- xterm: ^4.0.0
- checksum: 884d9f360893335c87e4514beeda2af6dbebf38a89b8518f1126d9c4611aefc1598b750bb43a953b79fdf1179c40d70c77a9169ae10f07e0abbbdb39b919b33f
- languageName: node
- linkType: hard
-
-"xterm-addon-search@npm:^0.8.0":
- version: 0.8.2
- resolution: "xterm-addon-search@npm:0.8.2"
- peerDependencies:
- xterm: ^4.0.0
- checksum: cb5fa8a551354d98d81c3f4792a43150670be119a0bf10fdff6727ee80ba2524682371f828bb175bd71075ca45989805560754bb22a30ed87d59725b7910cf1c
- languageName: node
- linkType: hard
-
-"xterm@npm:^4.9.0":
- version: 4.19.0
- resolution: "xterm@npm:4.19.0"
- checksum: 4385e08d6f1e26d0db295ba55f0ed9c304686a72c2cfdd32502cf59de23ae9c93434d469fc3735f44375602f209f767a1ba643a86be6f8e0f1cf7e5bfdccde87
- languageName: node
- linkType: hard
-
-"y18n@npm:^4.0.0":
- version: 4.0.3
- resolution: "y18n@npm:4.0.3"
- checksum: 014dfcd9b5f4105c3bb397c1c8c6429a9df004aa560964fb36732bfb999bfe83d45ae40aeda5b55d21b1ee53d8291580a32a756a443e064317953f08025b1aa4
- languageName: node
- linkType: hard
-
-"y18n@npm:^5.0.5":
- version: 5.0.8
- resolution: "y18n@npm:5.0.8"
- checksum: 54f0fb95621ee60898a38c572c515659e51cc9d9f787fb109cef6fde4befbe1c4602dc999d30110feee37456ad0f1660fa2edcfde6a9a740f86a290999550d30
- languageName: node
- linkType: hard
-
-"yallist@npm:^4.0.0":
- version: 4.0.0
- resolution: "yallist@npm:4.0.0"
- checksum: 343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5
- languageName: node
- linkType: hard
-
-"yargs-parser@npm:^18.1.2":
- version: 18.1.3
- resolution: "yargs-parser@npm:18.1.3"
- dependencies:
- camelcase: ^5.0.0
- decamelize: ^1.2.0
- checksum: 60e8c7d1b85814594d3719300ecad4e6ae3796748b0926137bfec1f3042581b8646d67e83c6fc80a692ef08b8390f21ddcacb9464476c39bbdf52e34961dd4d9
- languageName: node
- linkType: hard
-
-"yargs-parser@npm:^20.2.2":
- version: 20.2.9
- resolution: "yargs-parser@npm:20.2.9"
- checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3
- languageName: node
- linkType: hard
-
-"yargs-parser@npm:^21.1.1":
- version: 21.1.1
- resolution: "yargs-parser@npm:21.1.1"
- checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c
- languageName: node
- linkType: hard
-
-"yargs@npm:^15.0.1":
- version: 15.4.1
- resolution: "yargs@npm:15.4.1"
- dependencies:
- cliui: ^6.0.0
- decamelize: ^1.2.0
- find-up: ^4.1.0
- get-caller-file: ^2.0.1
- require-directory: ^2.1.1
- require-main-filename: ^2.0.0
- set-blocking: ^2.0.0
- string-width: ^4.2.0
- which-module: ^2.0.0
- y18n: ^4.0.0
- yargs-parser: ^18.1.2
- checksum: 40b974f508d8aed28598087720e086ecd32a5fd3e945e95ea4457da04ee9bdb8bdd17fd91acff36dc5b7f0595a735929c514c40c402416bbb87c03f6fb782373
- languageName: node
- linkType: hard
-
-"yargs@npm:^16.0.2":
- version: 16.2.0
- resolution: "yargs@npm:16.2.0"
- dependencies:
- cliui: ^7.0.2
- escalade: ^3.1.1
- get-caller-file: ^2.0.5
- require-directory: ^2.1.1
- string-width: ^4.2.0
- y18n: ^5.0.5
- yargs-parser: ^20.2.2
- checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59
- languageName: node
- linkType: hard
-
-"yargs@npm:^17.0.1":
- version: 17.7.2
- resolution: "yargs@npm:17.7.2"
- dependencies:
- cliui: ^8.0.1
- escalade: ^3.1.1
- get-caller-file: ^2.0.5
- require-directory: ^2.1.1
- string-width: ^4.2.3
- y18n: ^5.0.5
- yargs-parser: ^21.1.1
- checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a
- languageName: node
- linkType: hard
-
-"yarn-or-npm@npm:^3.0.1":
- version: 3.0.1
- resolution: "yarn-or-npm@npm:3.0.1"
- dependencies:
- cross-spawn: ^6.0.5
- pkg-dir: ^4.2.0
- bin:
- yarn-or-npm: bin/index.js
- yon: bin/index.js
- checksum: 94421b4315520075b4db6c09b6284064c047058d8bbe2663cdd4269491e5f7ea5d2e68eeaa0182a760a8757479cef665b7040a8c9ddb48a3da52587a8b712b27
- languageName: node
- linkType: hard
-
-"yauzl@npm:^2.10.0":
- version: 2.10.0
- resolution: "yauzl@npm:2.10.0"
- dependencies:
- buffer-crc32: ~0.2.3
- fd-slicer: ~1.1.0
- checksum: 7f21fe0bbad6e2cb130044a5d1d0d5a0e5bf3d8d4f8c4e6ee12163ce798fee3de7388d22a7a0907f563ac5f9d40f8699a223d3d5c1718da90b0156da6904022b
- languageName: node
- linkType: hard
-
-"yocto-queue@npm:^0.1.0":
- version: 0.1.0
- resolution: "yocto-queue@npm:0.1.0"
- checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700
- languageName: node
- linkType: hard
diff --git a/apps/nestjs-backend/.eslintrc.js b/apps/nestjs-backend/.eslintrc.js
index 0fc6a80460..9b9de3453e 100644
--- a/apps/nestjs-backend/.eslintrc.js
+++ b/apps/nestjs-backend/.eslintrc.js
@@ -34,5 +34,13 @@ module.exports = {
'@typescript-eslint/naming-convention': 'off',
},
},
+ {
+ // Disable consistent-type-imports for files with decorators (NestJS controllers/services)
+ // See: https://typescript-eslint.io/blog/changes-to-consistent-type-imports-with-decorators
+ files: ['src/**/*.controller.ts'],
+ rules: {
+ '@typescript-eslint/consistent-type-imports': 'off',
+ },
+ },
],
};
diff --git a/apps/nestjs-backend/.gitignore b/apps/nestjs-backend/.gitignore
index e746b599be..9bf376fd08 100644
--- a/apps/nestjs-backend/.gitignore
+++ b/apps/nestjs-backend/.gitignore
@@ -1,3 +1,13 @@
+# build
build
dist
-.temporary/*
\ No newline at end of file
+
+# testing
+/coverage
+
+# misc
+.DS_Store
+*.pem
+.assets
+.temporary
+.webpack-cache
diff --git a/apps/nestjs-backend/.idea/modules.xml b/apps/nestjs-backend/.idea/modules.xml
new file mode 100644
index 0000000000..f112c74da2
--- /dev/null
+++ b/apps/nestjs-backend/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/nestjs-backend/.idea/nestjs-backend.iml b/apps/nestjs-backend/.idea/nestjs-backend.iml
new file mode 100644
index 0000000000..24643cc374
--- /dev/null
+++ b/apps/nestjs-backend/.idea/nestjs-backend.iml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/nestjs-backend/README.md b/apps/nestjs-backend/README.md
index 17be4ead74..ad2274cace 100644
--- a/apps/nestjs-backend/README.md
+++ b/apps/nestjs-backend/README.md
@@ -1 +1,6 @@
# NestJS backend for teable
+
+TODO:
+remove @valibot/to-json-schema in ai-sdk6
+remove effect in ai-sdk6
+remove @ai-sdk/provider-utils in ai-sdk6
diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json
index e9ca478a81..54f4c7eb02 100644
--- a/apps/nestjs-backend/package.json
+++ b/apps/nestjs-backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@teable/backend",
- "version": "1.0.0",
+ "version": "1.10.0",
"license": "AGPL-3.0",
"private": true,
"main": "dist/index.js",
@@ -32,27 +32,33 @@
},
"scripts": {
"build": "nest build",
- "clean": "rimraf ./out ./coverage ./main ./dist ./tsconfig.tsbuildinfo ./node_modules/.cache",
+ "clean": "rimraf ./out ./coverage ./main ./dist ./tsconfig.tsbuildinfo ./node_modules/.cache .webpack-cache",
"dev": "nest start --webpackPath ./webpack.dev.js -w",
+ "dev:swc": "nest start --webpackPath ./webpack.swc.js -w",
"start": "nest start",
"check-dist": "es-check -v",
"start-debug": "nest start --webpackPath ./webpack.dev.js --debug -w",
"check-size": "size-limit --highlight-less",
"test": "run-s test-unit test-e2e",
+ "test-unit:watch": "vitest --watch",
"test-unit": "vitest run --silent --bail 1",
- "test-cov": "vitest run --coverage",
+ "test-unit-cover": "pnpm test-unit --coverage ${VITEST_SHARD:+--shard=$VITEST_SHARD}",
"pre-test-e2e": "cross-env NODE_ENV=test pnpm -F @teable/db-main-prisma prisma-db-seed -- --e2e",
- "test-e2e": "pnpm pre-test-e2e && vitest run --config ./vitest-e2e.config.ts --silent --bail 1",
+ "test-e2e": "pnpm pre-test-e2e && vitest run --config ./vitest-e2e.config.ts --silent",
+ "test-e2e-cover": "pnpm test-e2e --coverage --bail 1 ${VITEST_SHARD:+--shard=$VITEST_SHARD}",
"typecheck": "tsc --project ./tsconfig.json --noEmit",
"lint": "eslint . --ext .ts,.js,.cjs,.mjs,.mdx --cache --cache-location ../../.cache/eslint/nestjs-backend.eslintcache",
"fix-all-files": "eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs,.mdx --fix",
- "flamegraph-home": "npx 0x --output-dir './.debug/flamegraph/{pid}.0x' --on-port 'autocannon http://localhost:$PORT --duration 20' -- node ../../node_modules/.bin/next start"
+ "flamegraph-home": "npx 0x --output-dir './.debug/flamegraph/{pid}.0x' --on-port 'autocannon http://localhost:$PORT --duration 20' -- node ../../node_modules/.bin/next start",
+ "merge-cover": "istanbul-merge --out ./coverage/nestjs-backend/coverage-final.json ./coverage/e2e/coverage-final.json ./coverage/unit/coverage-final.json",
+ "generate-cover": "nyc report --report-dir=coverage/nestjs-backend --temp-dir=coverage/nestjs-backend -r text -r html -r clover"
},
"devDependencies": {
"@faker-js/faker": "8.4.1",
"@nestjs/cli": "10.3.2",
- "@nestjs/testing": "10.3.3",
+ "@nestjs/testing": "10.3.5",
"@teable/eslint-config-bases": "workspace:^",
+ "@types/archiver": "6.0.3",
"@types/bcrypt": "5.0.2",
"@types/cookie": "0.6.0",
"@types/cookie-parser": "1.4.7",
@@ -60,126 +66,213 @@
"@types/express": "4.17.21",
"@types/express-session": "1.18.0",
"@types/fs-extra": "11.0.4",
- "@types/lodash": "4.14.202",
+ "@types/lodash": "4.17.0",
"@types/markdown-it": "13.0.7",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/multer": "1.4.11",
- "@types/node": "20.9.0",
+ "@types/node": "22.18.0",
"@types/node-fetch": "2.6.11",
"@types/nodemailer": "6.4.14",
+ "@types/oauth2orize": "1.11.5",
+ "@types/oauth2orize-pkce": "0.1.2",
+ "@types/papaparse": "5.3.14",
"@types/passport": "1.0.16",
+ "@types/passport-github2": "1.2.9",
+ "@types/passport-google-oauth20": "2.0.14",
"@types/passport-jwt": "4.0.1",
"@types/passport-local": "1.0.38",
+ "@types/passport-oauth2-client-password": "0.1.5",
+ "@types/passport-openidconnect": "0.1.3",
"@types/pause": "0.1.3",
- "@types/sharedb": "3.3.10",
- "@types/ws": "8.5.10",
+ "@types/pg": "8.16.0",
+ "@types/sharedb": "5.1.0",
+ "@types/sockjs": "0.3.36",
+ "@types/sockjs-client": "1.5.4",
+ "@types/stream-json": "1.7.8",
+ "@types/through2": "2.0.41",
+ "@types/unzipper": "0.10.11",
+ "@types/ws": "8.18.1",
+ "@vitest/coverage-v8": "4.0.17",
"copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3",
"dotenv-flow": "4.1.0",
"dotenv-flow-cli": "1.1.1",
"es-check": "7.1.1",
"eslint": "8.57.0",
- "eslint-config-next": "14.1.3",
+ "eslint-config-next": "15.5.9",
"get-tsconfig": "4.7.3",
+ "istanbul-merge": "2.0.0",
"npm-run-all2": "6.1.2",
+ "nyc": "15.1.0",
+ "pg-mem": "3.0.5",
"prettier": "3.2.5",
"rimraf": "5.0.5",
+ "sockjs-client": "1.6.1",
+ "sql-formatter": "^15.3.1",
+ "swc-loader": "0.2.6",
"symlink-dir": "5.2.1",
"sync-directory": "6.0.5",
"ts-loader": "9.5.1",
"ts-node": "10.9.2",
- "typescript": "5.4.2",
+ "typescript": "5.4.3",
"unplugin-swc": "1.4.4",
- "vite-tsconfig-paths": "4.3.1",
- "vitest": "1.3.1",
- "vitest-mock-extended": "1.3.1",
- "webpack": "5.90.2"
+ "vite-tsconfig-paths": "4.3.2",
+ "vitest": "4.0.17",
+ "vitest-mock-extended": "2.0.2",
+ "webpack": "5.91.0"
},
"dependencies": {
+ "@ai-sdk/amazon-bedrock": "4.0.69",
+ "@ai-sdk/anthropic": "3.0.50",
+ "@ai-sdk/azure": "3.0.38",
+ "@ai-sdk/cohere": "3.0.22",
+ "@ai-sdk/deepseek": "2.0.21",
+ "@ai-sdk/google": "3.0.34",
+ "@ai-sdk/mistral": "3.0.21",
+ "@ai-sdk/openai": "3.0.37",
+ "@ai-sdk/openai-compatible": "2.0.31",
+ "@ai-sdk/togetherai": "2.0.35",
+ "@ai-sdk/xai": "3.0.60",
+ "@an-epiphany/websocket-json-stream": "1.2.0",
+ "@aws-sdk/client-s3": "3.609.0",
+ "@aws-sdk/lib-storage": "3.609.0",
+ "@aws-sdk/s3-request-presigner": "3.609.0",
"@keyv/redis": "2.8.4",
"@keyv/sqlite": "3.6.7",
"@nestjs-modules/mailer": "1.11.2",
"@nestjs/axios": "3.0.2",
- "@nestjs/common": "10.3.3",
- "@nestjs/config": "3.2.0",
- "@nestjs/core": "10.3.3",
+ "@nestjs/bullmq": "11.0.4",
+ "@nestjs/common": "10.3.5",
+ "@nestjs/config": "3.2.1",
+ "@nestjs/core": "10.3.5",
"@nestjs/event-emitter": "2.0.4",
"@nestjs/jwt": "10.2.0",
"@nestjs/passport": "10.0.3",
- "@nestjs/platform-express": "10.3.3",
- "@nestjs/platform-ws": "10.3.3",
+ "@nestjs/platform-express": "10.3.5",
+ "@nestjs/platform-ws": "10.3.5",
"@nestjs/swagger": "7.3.0",
+ "@nestjs/throttler": "6.4.0",
"@nestjs/terminus": "10.2.3",
- "@nestjs/websockets": "10.3.3",
- "@opentelemetry/api": "1.8.0",
- "@opentelemetry/context-async-hooks": "1.22.0",
- "@opentelemetry/exporter-trace-otlp-proto": "0.49.1",
- "@opentelemetry/instrumentation-express": "0.36.0",
- "@opentelemetry/instrumentation-http": "0.49.1",
- "@opentelemetry/instrumentation-pino": "0.36.0",
- "@opentelemetry/resources": "1.22.0",
- "@opentelemetry/sdk-node": "0.49.1",
- "@opentelemetry/semantic-conventions": "1.22.0",
- "@prisma/client": "5.10.2",
- "@prisma/instrumentation": "5.10.2",
+ "@nestjs/websockets": "10.3.5",
+ "@openrouter/ai-sdk-provider": "2.2.3",
+ "@opentelemetry/api": "1.9.0",
+ "@opentelemetry/context-async-hooks": "2.5.0",
+ "@opentelemetry/exporter-logs-otlp-http": "0.201.1",
+ "@opentelemetry/exporter-metrics-otlp-http": "0.201.1",
+ "@opentelemetry/exporter-trace-otlp-http": "0.201.1",
+ "@opentelemetry/instrumentation-express": "0.50.0",
+ "@opentelemetry/instrumentation-http": "0.201.1",
+ "@opentelemetry/instrumentation-ioredis": "0.49.0",
+ "@opentelemetry/instrumentation-nestjs-core": "0.49.0",
+ "@opentelemetry/instrumentation-pg": "0.49.0",
+ "@opentelemetry/instrumentation-pino": "0.49.0",
+ "@opentelemetry/instrumentation-runtime-node": "0.24.0",
+ "@opentelemetry/resources": "2.0.1",
+ "@opentelemetry/sdk-node": "0.201.1",
+ "@opentelemetry/sdk-trace-base": "2.0.1",
+ "@opentelemetry/semantic-conventions": "1.34.0",
+ "@orpc/nest": "1.13.0",
+ "@prisma/client": "6.2.1",
+ "@prisma/instrumentation": "6.2.1",
+ "@sentry/nestjs": "10.22.0",
+ "@sentry/opentelemetry": "10.22.0",
+ "@sentry/profiling-node": "10.22.0",
+ "@smithy/node-http-handler": "^3.1.1",
"@teable/common-i18n": "workspace:^",
"@teable/core": "workspace:^",
"@teable/db-main-prisma": "workspace:^",
"@teable/openapi": "workspace:^",
- "@teamwork/websocket-json-stream": "2.0.0",
- "@types/papaparse": "5.3.14",
+ "@teable/v2-adapter-db-postgres-pg": "workspace:*",
+ "@teable/v2-adapter-realtime-sharedb": "workspace:*",
+ "@teable/v2-adapter-undo-redo-keyv": "workspace:*",
+ "@teable/v2-container-node": "workspace:*",
+ "@teable/v2-contract-http": "workspace:*",
+ "@teable/v2-contract-http-implementation": "workspace:*",
+ "@teable/v2-contract-http-openapi": "workspace:*",
+ "@teable/v2-core": "workspace:*",
+ "@teable/v2-di": "workspace:*",
+ "@teable/v2-import": "workspace:*",
+ "@valibot/to-json-schema": "1.3.0",
+ "ai": "6.0.105",
"ajv": "8.12.0",
- "axios": "1.6.7",
+ "archiver": "7.0.1",
+ "axios": "1.7.7",
"bcrypt": "5.1.1",
+ "bullmq": "5.66.5",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"cookie": "0.6.0",
"cookie-parser": "1.4.6",
"cors": "2.8.5",
+ "csv-parser": "3.2.0",
+ "csv-stringify": "6.5.2",
+ "date-fns-tz": "3.2.0",
"dayjs": "1.11.10",
- "express": "4.18.3",
+ "effect": "3.19.1",
+ "esbuild": "0.23.0",
+ "express": "4.21.1",
"express-session": "1.18.0",
"fs-extra": "11.2.0",
"handlebars": "4.7.8",
"helmet": "7.1.0",
+ "http-proxy-middleware": "3.0.3",
+ "ioredis": "5.9.1",
"is-port-reachable": "3.1.0",
"joi": "17.12.2",
- "json-rules-engine": "6.5.0",
- "jsonpath-plus": "7.2.0",
+ "jschardet": "3.1.3",
+ "kysely": "0.28.9",
"keyv": "4.5.4",
"knex": "3.1.0",
"lodash": "4.17.21",
- "markdown-it": "14.0.0",
- "markdown-it-sanitizer": "0.4.3",
"mime-types": "2.1.35",
"minio": "7.1.3",
"ms": "2.1.3",
"multer": "1.4.5-lts.1",
"nanoid": "3.3.7",
- "nest-knexjs": "0.0.21",
- "nestjs-cls": "4.2.0",
- "nestjs-pino": "4.0.0",
+ "nest-knexjs": "0.0.22",
+ "nestjs-cls": "4.3.0",
+ "nestjs-i18n": "10.5.1",
+ "nestjs-pino": "4.4.1",
"nestjs-redoc": "2.2.2",
- "next": "14.1.3",
+ "next": "16.1.6",
"node-fetch": "2.7.0",
- "nodemailer": "6.9.11",
+ "node-sql-parser": "5.3.8",
+ "nodemailer": "6.9.13",
+ "oauth2orize": "1.12.0",
+ "oauth2orize-pkce": "0.1.2",
+ "object-sizeof": "2.6.4",
+ "ollama-ai-provider-v2": "3.0.2",
+ "p-limit": "3.1.0",
"papaparse": "5.4.1",
"passport": "0.7.0",
+ "passport-github2": "0.1.12",
+ "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"passport-local": "1.0.0",
+ "passport-oauth2-client-password": "0.1.2",
+ "passport-openidconnect": "0.1.2",
"pause": "0.1.0",
- "pino-http": "9.0.0",
- "pino-pretty": "10.3.1",
+ "pg": "8.11.5",
+ "pino-http": "10.5.0",
+ "pino-pretty": "11.0.0",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "redlock": "5.0.0-beta.2",
"reflect-metadata": "0.2.1",
+ "request-filtering-agent": "3.2.0",
"rxjs": "7.8.1",
- "sharedb": "4.1.2",
- "sharedb-redis-pubsub": "5.0.0",
- "sharp": "0.33.2",
+ "sharedb": "5.2.2",
+ "sharp": "0.33.3",
+ "sockjs": "0.3.24",
+ "stream-json": "1.9.1",
+ "through2": "4.0.2",
"transliteration": "2.3.5",
"ts-pattern": "5.0.8",
- "ws": "8.16.0",
- "zod": "3.22.4",
- "zod-validation-error": "3.0.3"
+ "unzipper": "0.12.3",
+ "ws": "8.18.3",
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
+ "zod": "4.1.8",
+ "zod-validation-error": "4.0.2"
}
}
diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts
index 20517a5c2f..714ed3783b 100644
--- a/apps/nestjs-backend/src/app.module.ts
+++ b/apps/nestjs-backend/src/app.module.ts
@@ -1,40 +1,82 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { BullModule } from '@nestjs/bullmq';
+import type { ModuleMetadata } from '@nestjs/common';
import { Module } from '@nestjs/common';
+import { ConditionalModule, ConfigService } from '@nestjs/config';
+import { SentryModule } from '@sentry/nestjs/setup';
+import Redis from 'ioredis';
+import type { ICacheConfig } from './configs/cache.config';
+import { ConfigModule } from './configs/config.module';
import { AccessTokenModule } from './features/access-token/access-token.module';
import { AggregationOpenApiModule } from './features/aggregation/open-api/aggregation-open-api.module';
+import { AiModule } from './features/ai/ai.module';
import { AttachmentsModule } from './features/attachments/attachments.module';
import { AuthModule } from './features/auth/auth.module';
-import { AutomationModule } from './features/automation/automation.module';
import { BaseModule } from './features/base/base.module';
+import { BaseNodeModule } from './features/base-node/base-node.module';
+import { BuiltinAssetsInitModule } from './features/builtin-assets-init';
+import { CanaryModule } from './features/canary';
import { ChatModule } from './features/chat/chat.module';
import { CollaboratorModule } from './features/collaborator/collaborator.module';
+import { CommentOpenApiModule } from './features/comment/comment-open-api.module';
+import { DashboardModule } from './features/dashboard/dashboard.module';
+import { ExportOpenApiModule } from './features/export/open-api/export-open-api.module';
import { FieldOpenApiModule } from './features/field/open-api/field-open-api.module';
import { HealthModule } from './features/health/health.module';
import { ImportOpenApiModule } from './features/import/open-api/import-open-api.module';
+import { IntegrityModule } from './features/integrity/integrity.module';
import { InvitationModule } from './features/invitation/invitation.module';
+import { MailSenderOpenApiModule } from './features/mail-sender/open-api/mail-sender-open-api.module';
+import { MailSenderMergeModule } from './features/mail-sender/open-api/mail-sender.merge.module';
import { NextModule } from './features/next/next.module';
import { NotificationModule } from './features/notification/notification.module';
+import { OAuthModule } from './features/oauth/oauth.module';
+import { OrganizationModule } from './features/organization/organization.module';
+import { PinModule } from './features/pin/pin.module';
+import { PluginChartModule } from './features/plugin/official/chart/plugin-chart.module';
+import { PluginModule } from './features/plugin/plugin.module';
+import { PluginContextMenuModule } from './features/plugin-context-menu/plugin-context-menu.module';
+import { PluginPanelModule } from './features/plugin-panel/plugin-panel.module';
import { SelectionModule } from './features/selection/selection.module';
+import { AdminOpenApiModule } from './features/setting/open-api/admin-open-api.module';
+import { SettingOpenApiModule } from './features/setting/open-api/setting-open-api.module';
+import { BaseShareModule } from './features/base-share/base-share.module';
import { ShareModule } from './features/share/share.module';
import { SpaceModule } from './features/space/space.module';
+import { TemplateOpenApiModule } from './features/template/template-open-api.module';
+import { TrashModule } from './features/trash/trash.module';
+import { UndoRedoModule } from './features/undo-redo/open-api/undo-redo.module';
import { UserModule } from './features/user/user.module';
+import { V2Module } from './features/v2/v2.module';
import { GlobalModule } from './global/global.module';
import { InitBootstrapProvider } from './global/init-bootstrap.provider';
import { LoggerModule } from './logger/logger.module';
+import { ObservabilityModule } from './observability/observability.module';
import { WsModule } from './ws/ws.module';
-@Module({
+// In CI or test environments, use a longer timeout for ConditionalModule
+// to avoid sporadic timeout errors when resources are under pressure
+const isTestOrCI = process.env.CI || process.env.NODE_ENV === 'test' || process.env.VITEST;
+const CONDITIONAL_MODULE_TIMEOUT = isTestOrCI ? 60000 : 5000;
+
+export const appModules = {
imports: [
- GlobalModule,
+ SentryModule.forRoot(),
LoggerModule.register(),
+ MailSenderOpenApiModule,
+ MailSenderMergeModule,
HealthModule,
NextModule,
FieldOpenApiModule,
+ TemplateOpenApiModule,
BaseModule,
+ BaseNodeModule,
+ IntegrityModule,
ChatModule,
AttachmentsModule,
- AutomationModule,
WsModule,
SelectionModule,
+ UndoRedoModule,
AggregationOpenApiModule,
UserModule,
AuthModule,
@@ -42,10 +84,67 @@ import { WsModule } from './ws/ws.module';
CollaboratorModule,
InvitationModule,
ShareModule,
+ BaseShareModule,
NotificationModule,
AccessTokenModule,
ImportOpenApiModule,
+ ExportOpenApiModule,
+ PinModule,
+ AdminOpenApiModule,
+ CanaryModule,
+ SettingOpenApiModule,
+ OAuthModule,
+ TrashModule,
+ DashboardModule,
+ CommentOpenApiModule,
+ OrganizationModule,
+ AiModule,
+ PluginModule,
+ PluginPanelModule,
+ PluginContextMenuModule,
+ PluginChartModule,
+ ObservabilityModule,
+ BuiltinAssetsInitModule,
+ V2Module,
],
providers: [InitBootstrapProvider],
+};
+
+@Module({
+ ...appModules,
+ imports: [
+ GlobalModule,
+ ...appModules.imports,
+ ConditionalModule.registerWhen(
+ BullModule.forRootAsync({
+ imports: [ConfigModule],
+ useFactory: async (configService: ConfigService) => {
+ const redisUri = configService.get('cache')?.redis.uri;
+ if (!redisUri) {
+ throw new Error('Redis URI is not defined');
+ }
+ const redis = new Redis(redisUri, { lazyConnect: true, maxRetriesPerRequest: null });
+ await redis.connect();
+
+ return {
+ connection: redis,
+ };
+ },
+ inject: [ConfigService],
+ }),
+ (env) => {
+ return Boolean(env.BACKEND_CACHE_REDIS_URI);
+ },
+ { timeout: CONDITIONAL_MODULE_TIMEOUT }
+ ),
+ ],
+ controllers: [],
})
-export class AppModule {}
+export class AppModule {
+ static register(customModuleMetadata: ModuleMetadata) {
+ return {
+ module: AppModule,
+ ...customModuleMetadata,
+ };
+ }
+}
diff --git a/apps/nestjs-backend/src/bootstrap.ts b/apps/nestjs-backend/src/bootstrap.ts
index a08ca6edcc..a0fd8fc09a 100644
--- a/apps/nestjs-backend/src/bootstrap.ts
+++ b/apps/nestjs-backend/src/bootstrap.ts
@@ -1,38 +1,29 @@
import 'dayjs/plugin/timezone';
import 'dayjs/plugin/utc';
-import fs from 'fs';
-import path from 'path';
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
-import { WsAdapter } from '@nestjs/platform-ws';
-import { SwaggerModule } from '@nestjs/swagger';
-import { getOpenApiDocumentation } from '@teable/openapi';
import { json, urlencoded } from 'express';
import helmet from 'helmet';
import isPortReachable from 'is-port-reachable';
import { Logger } from 'nestjs-pino';
-import type { RedocOptions } from 'nestjs-redoc';
-import { RedocModule } from 'nestjs-redoc';
import { AppModule } from './app.module';
import type { IBaseConfig } from './configs/base.config';
import type { ISecurityWebConfig, IApiDocConfig } from './configs/bootstrap.config';
import { GlobalExceptionFilter } from './filter/global-exception.filter';
-import otelSDK from './tracing';
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-declare const module: any;
+import { setupSwagger } from './swagger';
const host = 'localhost';
export async function setUpAppMiddleware(app: INestApplication, configService: ConfigService) {
- app.useWebSocketAdapter(new WsAdapter(app));
app.useGlobalFilters(new GlobalExceptionFilter(configService));
app.useGlobalPipes(
new ValidationPipe({ transform: true, stopAtFirstError: true, forbidUnknownValues: false })
);
- app.use(helmet());
+ // HSTS is configured at the WAF level. Disable it here to avoid sending duplicate
+ // `Strict-Transport-Security` headers with potentially different max-age values.
+ app.use(helmet({ hsts: false }));
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: true }));
@@ -40,25 +31,7 @@ export async function setUpAppMiddleware(app: INestApplication, configService: C
const securityWebConfig = configService.get('security.web');
const baseConfig = configService.get('base');
if (!apiDocConfig?.disabled) {
- const openApiDocumentation = await getOpenApiDocumentation({
- origin: baseConfig?.publicOrigin,
- snippet: apiDocConfig?.enabledSnippet,
- });
-
- const jsonString = JSON.stringify(openApiDocumentation);
- fs.writeFileSync(path.join(__dirname, '/openapi.json'), jsonString);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- SwaggerModule.setup('/docs', app, openApiDocumentation as any);
-
- // Instead of using SwaggerModule.setup() you call this module
- const redocOptions: RedocOptions = {
- logo: {
- backgroundColor: '#F0F0F0',
- altText: 'Teable logo',
- },
- };
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await RedocModule.setup('/redocs', app, openApiDocumentation as any, redocOptions);
+ await setupSwagger(app, baseConfig?.publicOrigin ?? '', apiDocConfig?.enabledSnippet ?? false);
}
if (securityWebConfig?.cors.enabled) {
@@ -67,16 +40,9 @@ export async function setUpAppMiddleware(app: INestApplication, configService: C
}
export async function bootstrap() {
- otelSDK.start();
-
const app = await NestFactory.create(AppModule, { bufferLogs: true });
const configService = app.get(ConfigService);
- if (module.hot) {
- module.hot.accept();
- module.hot.dispose(() => app.close());
- }
-
const logger = app.get(Logger);
app.useLogger(logger);
app.flushLogs();
diff --git a/apps/nestjs-backend/src/cache/cache.module.ts b/apps/nestjs-backend/src/cache/cache.module.ts
index ae8c76c623..b79366ae4f 100644
--- a/apps/nestjs-backend/src/cache/cache.module.ts
+++ b/apps/nestjs-backend/src/cache/cache.module.ts
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { ConfigurableModuleBuilder, type DynamicModule, Module } from '@nestjs/common';
import { CacheProvider } from './cache.provider';
+import { RedisNativeService } from './redis-native.service';
export interface CacheModuleOptions {
global?: boolean;
@@ -10,8 +11,8 @@ export const { ConfigurableModuleClass: CacheModuleClass, OPTIONS_TYPE } =
new ConfigurableModuleBuilder().build();
@Module({
- providers: [CacheProvider],
- exports: [CacheProvider],
+ providers: [CacheProvider, RedisNativeService],
+ exports: [CacheProvider, RedisNativeService],
})
export class CacheModule extends CacheModuleClass {
static register(options: typeof OPTIONS_TYPE): DynamicModule {
diff --git a/apps/nestjs-backend/src/cache/cache.service.ts b/apps/nestjs-backend/src/cache/cache.service.ts
index b754c74ee9..6f1588bd3f 100644
--- a/apps/nestjs-backend/src/cache/cache.service.ts
+++ b/apps/nestjs-backend/src/cache/cache.service.ts
@@ -1,26 +1,156 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
import { getRandomInt } from '@teable/core';
-import { type Store } from 'keyv';
+import type { Redis } from 'ioredis';
+import Keyv from 'keyv';
+import { second } from '../utils/second';
import type { ICacheStore } from './types';
@Injectable()
-export class CacheService {
+export class CacheService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- constructor(private readonly cacheManager: Store) {}
+ constructor(private readonly cacheManager: Keyv) {}
+ private readonly logger = new Logger(CacheService.name);
- async get(key: TKey): Promise {
- return this.cacheManager.get(key);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ getKeyv(): Keyv {
+ return this.cacheManager;
+ }
+
+ /**
+ * Get the underlying Redis client if available
+ * Returns undefined if not using Redis
+ */
+ private getRedisClient(): Redis | undefined {
+ try {
+ // KeyvRedis stores the Redis client in store.redis
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const store = this.cacheManager.opts?.store as any;
+ return store?.redis || store?.client;
+ } catch {
+ return undefined;
+ }
}
- async set(
+ /**
+ * Atomic set-if-not-exists operation (Redis SETNX with EX)
+ * Returns true if the key was set, false if it already existed
+ * @param key - The key to set
+ * @param value - The value to set
+ * @param ttlSeconds - TTL in seconds
+ */
+ async setnx(
key: TKey,
- value: ICacheStore[TKey],
- ttl?: number
+ value: T[TKey],
+ ttlSeconds: number
+ ): Promise {
+ const redis = this.getRedisClient();
+ if (!redis) {
+ // Fallback for non-Redis: not truly atomic, but better than nothing
+ const existing = await this.get(key);
+ if (existing !== undefined) {
+ return false;
+ }
+ await this.setDetail(key, value, ttlSeconds);
+ return true;
+ }
+
+ // Use Redis SET with NX and EX for atomic operation
+ const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;
+ const serializedValue = JSON.stringify(value);
+ const result = await redis.set(fullKey, serializedValue, 'EX', ttlSeconds, 'NX');
+ return result === 'OK';
+ }
+
+ /**
+ * Atomic increment operation (Redis INCR with optional EX)
+ * Returns the new value after increment
+ * @param key - The key to increment
+ * @param ttlSeconds - Optional TTL in seconds (only set on first increment)
+ */
+ async incr(key: TKey, ttlSeconds?: number): Promise {
+ const redis = this.getRedisClient();
+ if (!redis) {
+ // Fallback for non-Redis: not truly atomic
+ const current = (await this.get(key)) as number | undefined;
+ const newValue = (current || 0) + 1;
+ await this.setDetail(key, newValue as T[TKey], ttlSeconds);
+ return newValue;
+ }
+
+ const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;
+ const newValue = await redis.incr(fullKey);
+
+ // Set TTL only if provided and this is the first increment (value is 1)
+ if (ttlSeconds && newValue === 1) {
+ await redis.expire(fullKey, ttlSeconds);
+ }
+
+ return newValue;
+ }
+
+ private warnNotSetTTL(key: string, ttl?: number) {
+ if (!ttl || Number.isNaN(ttl) || ttl <= 0) {
+ this.logger.warn(`[Cache Service] Not set ttl for key: ${key}`);
+ }
+ }
+
+ async get(key: TKey): Promise {
+ return this.cacheManager.get(key as string);
+ }
+
+ async set(
+ key: TKey,
+ value: T[TKey],
+ // seconds, and will add random 20-60 seconds
+ ttl?: number | string
+ ): Promise {
+ const numberTTL = typeof ttl === 'string' ? second(ttl) : ttl;
+ this.warnNotSetTTL(key as string, numberTTL);
+ await this.cacheManager.set(
+ key as string,
+ value,
+ numberTTL ? (numberTTL + getRandomInt(20, 60)) * 1000 : undefined
+ );
+ }
+
+ // no add random ttl
+ async setDetail(
+ key: TKey,
+ value: T[TKey],
+ ttl?: number | string // seconds
): Promise {
- await this.cacheManager.set(key, value, ttl ? (ttl + getRandomInt(20, 60)) * 1000 : undefined);
+ const numberTTL = typeof ttl === 'string' ? second(ttl) : ttl;
+ this.warnNotSetTTL(key as string, numberTTL);
+ await this.cacheManager.set(key as string, value, numberTTL ? numberTTL * 1000 : undefined);
+ }
+
+ async del(key: TKey): Promise {
+ await this.cacheManager.delete(key as string);
}
- async del(key: TKey): Promise {
- await this.cacheManager.delete(key);
+ async getMany(keys: TKey[]): Promise> {
+ return this.cacheManager.get(keys as string[]);
+ }
+
+ /**
+ * Update the TTL of an existing key without reading/writing data
+ * Returns true if the key exists and TTL was updated
+ */
+ async expire(key: TKey, ttl: number | string): Promise {
+ const ttlSeconds = typeof ttl === 'string' ? second(ttl) : ttl;
+ const redis = this.getRedisClient();
+ if (!redis) {
+ // Fallback for non-Redis: get and re-set
+ const value = await this.get(key);
+ if (value !== undefined) {
+ await this.setDetail(key, value, ttlSeconds);
+ return true;
+ }
+ return false;
+ }
+
+ const fullKey = `${this.cacheManager.opts.namespace}:${key as string}`;
+ const result = await redis.expire(fullKey, ttlSeconds);
+ return result === 1;
}
}
diff --git a/apps/nestjs-backend/src/cache/redis-native.service.ts b/apps/nestjs-backend/src/cache/redis-native.service.ts
new file mode 100644
index 0000000000..c539d24336
--- /dev/null
+++ b/apps/nestjs-backend/src/cache/redis-native.service.ts
@@ -0,0 +1,329 @@
+import { Injectable, Logger } from '@nestjs/common';
+import type { Redis } from 'ioredis';
+import { CacheService } from './cache.service';
+
+/**
+ * Type-safe wrapper around the ioredis client obtained from CacheService.
+ *
+ * Provides:
+ * - Normalized return types (e.g. `exists` → boolean, `sismember` → boolean)
+ * - Defensive guards (empty array protection for variadic commands)
+ * - Consistent error when Redis is unavailable
+ */
+@Injectable()
+export class RedisNativeService {
+ private readonly logger = new Logger(RedisNativeService.name);
+ private readonly redis: Redis | undefined;
+
+ constructor(cacheService: CacheService) {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const store = cacheService.getKeyv().opts?.store as any;
+ this.redis = store?.redis || store?.client;
+ } catch {
+ this.redis = undefined;
+ }
+ if (!this.redis) {
+ this.logger.warn('Redis client not available — RedisNativeService disabled');
+ }
+ }
+
+ private get client(): Redis {
+ if (!this.redis) {
+ throw new Error('RedisNativeService: Redis is not available (cache provider is not redis)');
+ }
+ return this.redis;
+ }
+
+ /**
+ * Get the value of a string key.
+ * @param key - Redis key
+ * @returns Value string, or null if key doesn't exist
+ */
+ async get(key: string): Promise {
+ return this.client.get(key);
+ }
+
+ /**
+ * Set multiple fields on a hash key atomically. No-op if fields is empty.
+ * @param key - Redis hash key
+ * @param fields - Key-value pairs to set
+ */
+ async hset(key: string, fields: Record): Promise {
+ const entries = Object.entries(fields).flat();
+ if (entries.length > 0) {
+ await this.client.hset(key, ...entries);
+ }
+ }
+
+ /**
+ * Get all fields and values of a hash.
+ * @param key - Redis hash key
+ * @returns All field-value pairs, or null if key doesn't exist
+ */
+ async hgetall(key: string): Promise | null> {
+ const result = await this.client.hgetall(key);
+ return Object.keys(result).length > 0 ? result : null;
+ }
+
+ /**
+ * Get a single field value from a hash.
+ * @param key - Redis hash key
+ * @param field - Field name within the hash
+ * @returns Field value, or null if field or key doesn't exist
+ */
+ async hget(key: string, field: string): Promise {
+ return this.client.hget(key, field);
+ }
+
+ /**
+ * Get multiple field values from a hash in a single round-trip.
+ * @param key - Redis hash key
+ * @param fields - Field names to fetch
+ * @returns Array of values in the same order as fields (null for missing fields)
+ */
+ async hmget(key: string, ...fields: string[]): Promise<(string | null)[]> {
+ if (fields.length === 0) return [];
+ return this.client.hmget(key, ...fields);
+ }
+
+ /**
+ * Delete one or more fields from a hash. No-op if fields list is empty.
+ * @param key - Redis hash key
+ * @param fields - Field names to delete
+ */
+ async hdel(key: string, ...fields: string[]): Promise {
+ if (fields.length > 0) {
+ await this.client.hdel(key, ...fields);
+ }
+ }
+
+ /**
+ * Set a TTL (time-to-live) on an existing key.
+ * @param key - Redis key
+ * @param seconds - TTL in seconds
+ */
+ async expire(key: string, seconds: number): Promise {
+ await this.client.expire(key, seconds);
+ }
+
+ /**
+ * Get remaining TTL (in seconds) for a key.
+ * Redis semantics:
+ * - -2: key does not exist
+ * - -1: key exists but has no associated expire
+ */
+ async ttl(key: string): Promise {
+ return this.client.ttl(key);
+ }
+
+ /**
+ * Delete a key.
+ * @param key - Redis key to delete
+ */
+ async del(key: string): Promise {
+ await this.client.del(key);
+ }
+
+ /**
+ * Check if a key exists.
+ * @param key - Redis key
+ * @returns true if the key exists, false otherwise
+ */
+ async exists(key: string): Promise {
+ const result = await this.client.exists(key);
+ return result === 1;
+ }
+
+ /**
+ * Set a key with a value and TTL (SETEX command).
+ * @param key - Redis key
+ * @param seconds - TTL in seconds
+ * @param value - Value to store
+ */
+ async setex(key: string, seconds: number, value: string): Promise {
+ await this.client.setex(key, seconds, value);
+ }
+
+ /**
+ * Atomic set-if-not-exists with TTL (SET key value NX EX seconds).
+ * @param key - Redis key
+ * @param seconds - TTL in seconds
+ * @param value - Value to store
+ * @returns true if the key was set (didn't exist), false if it already existed
+ */
+ async setnxex(key: string, seconds: number, value: string): Promise {
+ const result = await this.client.set(key, value, 'EX', seconds, 'NX');
+ return result === 'OK';
+ }
+
+ /**
+ * Add a member with a score to a sorted set.
+ * @param key - Redis sorted set key
+ * @param score - Score for ordering
+ * @param member - Member value
+ */
+ async zadd(key: string, score: number, member: string): Promise {
+ await this.client.zadd(key, score, member);
+ }
+
+ /**
+ * Get all members with scores in the given range (inclusive).
+ * @param key - Redis sorted set key
+ * @param min - Minimum score (number or '-inf')
+ * @param max - Maximum score (number or '+inf')
+ * @returns Array of member values within the score range
+ */
+ async zrangebyscore(key: string, min: number | string, max: number | string): Promise {
+ return this.client.zrangebyscore(key, min, max);
+ }
+
+ /**
+ * Remove one or more members from a sorted set. No-op if members list is empty.
+ * @param key - Redis sorted set key
+ * @param members - Members to remove
+ */
+ async zrem(key: string, ...members: string[]): Promise {
+ if (members.length > 0) {
+ await this.client.zrem(key, ...members);
+ }
+ }
+
+ /**
+ * Add one or more members to a set. No-op if members list is empty.
+ * @param key - Redis set key
+ * @param members - Members to add
+ * @returns Number of new members actually added (excludes already-existing)
+ */
+ async sadd(key: string, ...members: string[]): Promise {
+ if (members.length === 0) return 0;
+ return this.client.sadd(key, ...members);
+ }
+
+ /**
+ * Remove one or more members from a set. No-op if members list is empty.
+ * @param key - Redis set key
+ * @param members - Members to remove
+ * @returns Number of members actually removed
+ */
+ async srem(key: string, ...members: string[]): Promise {
+ if (members.length === 0) return 0;
+ return this.client.srem(key, ...members);
+ }
+
+ /**
+ * Check if a member exists in a set.
+ * @param key - Redis set key
+ * @param member - Member to check
+ * @returns true if the member exists in the set, false otherwise
+ */
+ async sismember(key: string, member: string): Promise {
+ const result = await this.client.sismember(key, member);
+ return result === 1;
+ }
+
+ /**
+ * Get the number of members in a set (cardinality).
+ * @param key - Redis set key
+ * @returns Number of members in the set
+ */
+ async scard(key: string): Promise {
+ return this.client.scard(key);
+ }
+
+ /**
+ * Execute a Lua script atomically on the Redis server.
+ * @param script - Lua script source code
+ * @param keys - KEYS array accessible in Lua as KEYS[1], KEYS[2], ...
+ * @param args - ARGV array accessible in Lua as ARGV[1], ARGV[2], ...
+ * @returns Script return value (type depends on the Lua script)
+ */
+ async eval(script: string, keys: string[], args: (string | number)[]): Promise {
+ return this.client.eval(script, keys.length, ...keys, ...args);
+ }
+
+ /**
+ * Execute multiple commands in a single network roundtrip (pipeline).
+ * @param commands - Array of operations, each with an op type, key, and optional args
+ */
+ async pipeline(
+ commands: Array<{ op: 'del' | 'zrem' | 'srem'; key: string; args?: string[] }>
+ ): Promise {
+ const pipe = this.client.pipeline();
+ for (const cmd of commands) {
+ switch (cmd.op) {
+ case 'del':
+ pipe.del(cmd.key);
+ break;
+ case 'zrem':
+ if (cmd.args && cmd.args.length > 0) {
+ pipe.zrem(cmd.key, ...cmd.args);
+ }
+ break;
+ case 'srem':
+ if (cmd.args && cmd.args.length > 0) {
+ pipe.srem(cmd.key, ...cmd.args);
+ }
+ break;
+ }
+ }
+ await pipe.exec();
+ }
+
+ /**
+ * Batch HGETALL via pipeline — single network roundtrip for multiple hash keys.
+ * @param keys - Array of Redis hash keys
+ * @returns Array of field-value maps (null for missing/empty hashes)
+ */
+ async hgetallMulti(keys: string[]): Promise | null>> {
+ if (keys.length === 0) return [];
+ const pipe = this.client.pipeline();
+ for (const key of keys) {
+ pipe.hgetall(key);
+ }
+ const replies = await pipe.exec();
+ return keys.map((_, i) => {
+ const [err, raw] = replies?.[i] ?? [null, null];
+ if (err || !raw || typeof raw !== 'object' || Object.keys(raw as object).length === 0) {
+ return null;
+ }
+ return raw as Record;
+ });
+ }
+
+ /**
+ * Batch SCARD via pipeline — single network roundtrip for multiple set keys.
+ * @param keys - Array of Redis set keys
+ * @returns Array of cardinalities (0 for missing keys or errors)
+ */
+ async scardMulti(keys: string[]): Promise {
+ if (keys.length === 0) return [];
+ const pipe = this.client.pipeline();
+ for (const key of keys) {
+ pipe.scard(key);
+ }
+ const replies = await pipe.exec();
+ return keys.map((_, i) => {
+ const [err, count] = replies?.[i] ?? [null, 0];
+ return err ? 0 : (count as number) ?? 0;
+ });
+ }
+
+ /**
+ * Batch EXISTS via pipeline — single network roundtrip for multiple keys.
+ * @param keys - Array of Redis keys
+ * @returns Array of booleans (true if key exists)
+ */
+ async existsMulti(keys: string[]): Promise {
+ if (keys.length === 0) return [];
+ const pipe = this.client.pipeline();
+ for (const key of keys) {
+ pipe.exists(key);
+ }
+ const replies = await pipe.exec();
+ return keys.map((_, i) => {
+ const [err, result] = replies?.[i] ?? [null, 0];
+ return err ? false : (result as number) === 1;
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/cache/types.ts b/apps/nestjs-backend/src/cache/types.ts
index fb93ac80c9..c200ccd5cd 100644
--- a/apps/nestjs-backend/src/cache/types.ts
+++ b/apps/nestjs-backend/src/cache/types.ts
@@ -1,3 +1,8 @@
+import type { IColumnMeta, IFieldVo, IOtOperation, IViewPropertyKeys, IViewVo } from '@teable/core';
+import type { IRecord, MailType } from '@teable/openapi';
+import type { ICellContext } from '../features/calculation/utils/changes';
+import type { IOpsMap } from '../features/calculation/utils/compose-maps';
+import type { ISendMailOptions } from '../features/mail-sender/mail-helpers';
import type { ISessionData } from '../types/session';
/* eslint-disable @typescript-eslint/naming-convention */
@@ -9,6 +14,41 @@ export interface ICacheStore {
[key: `auth:session-store:${string}`]: ISessionData;
[key: `auth:session-user:${string}`]: Record;
[key: `auth:session-expire:${string}`]: boolean;
+ [key: `oauth2:${string}`]: IOauth2State;
+ [key: `reset-password-email:${string}`]: IResetPasswordEmailCache;
+ [key: `workflow:running:${string}`]: string;
+ [key: `workflow:repeatKey:${string}`]: string;
+ [key: `oauth:code:${string}`]: IOAuthCodeState;
+ [key: `oauth:txn:${string}`]: IOAuthTxnStore;
+ // userId:tableId:windowId
+ [key: `operations:undo:${string}:${string}:${string}`]: IUndoRedoOperation[];
+ [key: `operations:redo:${string}:${string}:${string}`]: IUndoRedoOperation[];
+ [key: `operations:engine:${string}:${string}:${string}`]: 'v1' | 'v2';
+ [key: `plugin:auth-code:${string}`]: IPluginAuthStore;
+ [key: `signin:attempts:${string}`]: number;
+ [key: `signin:lockout:${string}`]: boolean;
+ [key: `query-params:${string}`]: Record;
+ [key: `mail-sender:notify-mail-merge:${string}`]: (ISendMailOptions & {
+ mailType: MailType;
+ })[];
+ [key: `waitlist:invite-code:${string}`]: number;
+ [key: `send-mail-rate-limit:${string}`]: boolean;
+ [key: `oauth:token-rate:${string}:${string}`]: number;
+ [key: `automation:email:rate:${string}:${number}`]: number;
+ [key: `automation:email-att:${string}`]: string[];
+ // Distributed lock keys
+ [key: `lock:${string}`]: string;
+ [key: `import:result:manifest:${string}`]: {
+ successCount: number;
+ failedCount: number;
+ errorFilePaths: string[];
+ fieldNames: string[];
+ maxWidth: number;
+ errorReportUrl?: string;
+ };
+ [key: `import:latest-job:${string}`]: string;
+ // trash cleanup: per-item backoff after failed cleanup attempts
+ [key: `trash-cleanup:skipped:${string}`]: { attempts: number; retryAfter: number };
}
export interface IAttachmentSignatureCache {
@@ -33,3 +73,224 @@ export interface IAttachmentPreviewCache {
url: string;
expiresIn: number;
}
+
+export interface IOauth2State {
+ redirectUri?: string;
+}
+
+export interface IResetPasswordEmailCache {
+ userId: string;
+}
+
+export interface IOAuthCodeState {
+ scopes: string[];
+ redirectUri: string;
+ clientId: string;
+ user: {
+ id: string;
+ name: string;
+ email: string;
+ };
+ codeChallenge?: string;
+ codeChallengeMethod?: 'S256';
+}
+
+export interface IOAuthTxnStore {
+ redirectURI: string;
+ clientId: string;
+ type: string;
+ scopes: string[];
+ userId: string;
+ state?: string;
+ codeChallenge?: string;
+ codeChallengeMethod?: string;
+}
+
+export enum OperationName {
+ CreateView = 'createView',
+ DeleteView = 'deleteView',
+ UpdateView = 'updateView',
+ CreateRecords = 'createRecords',
+ DeleteRecords = 'deleteRecords',
+ UpdateRecords = 'updateRecords',
+ UpdateRecordsOrder = 'updateRecordsOrder',
+ CreateFields = 'createFields',
+ ConvertField = 'convertField',
+ ConvertFieldV2 = 'convertFieldV2',
+ DeleteFields = 'deleteFields',
+ PasteSelection = 'pasteSelection',
+}
+
+export interface IUndoRedoOperationBase {
+ name: OperationName;
+ params: Record;
+ result?: unknown;
+ userId?: string;
+ operationId?: string;
+}
+
+export interface IUpdateRecordsOperation extends IUndoRedoOperationBase {
+ name: OperationName.UpdateRecords;
+ params: {
+ tableId: string;
+ recordIds: string[];
+ fieldIds: string[];
+ };
+ result: {
+ cellContexts?: ICellContext[];
+ ordersMap?: {
+ [recordId: string]: {
+ newOrder?: Record;
+ oldOrder?: Record;
+ };
+ };
+ };
+}
+
+export interface IUpdateRecordsOrderOperation extends IUndoRedoOperationBase {
+ name: OperationName.UpdateRecordsOrder;
+ params: {
+ tableId: string;
+ viewId: string;
+ recordIds: string[];
+ };
+ result: {
+ ordersMap?: {
+ [recordId: string]: {
+ newOrder?: Record;
+ oldOrder?: Record;
+ };
+ };
+ };
+}
+
+export interface ICreateRecordsOperation extends IUndoRedoOperationBase {
+ name: OperationName.CreateRecords;
+ params: {
+ tableId: string;
+ };
+ result: {
+ records: (IRecord & { order?: Record })[];
+ };
+}
+
+export interface IDeleteRecordsOperation extends Omit {
+ name: OperationName.DeleteRecords;
+}
+
+export interface IConvertFieldOperation extends IUndoRedoOperationBase {
+ name: OperationName.ConvertField;
+ params: {
+ tableId: string;
+ };
+ result: {
+ oldField: IFieldVo;
+ newField: IFieldVo;
+ modifiedOps?: IOpsMap;
+ references?: string[];
+ supplementChange?: {
+ tableId: string;
+ newField: IFieldVo;
+ oldField: IFieldVo;
+ };
+ };
+}
+
+export interface IConvertFieldV2Operation extends IUndoRedoOperationBase {
+ name: OperationName.ConvertFieldV2;
+ params: {
+ tableId: string;
+ };
+ result: {
+ oldField: IFieldVo;
+ newField: IFieldVo;
+ modifiedOps?: IOpsMap;
+ references?: string[];
+ };
+}
+
+export interface ICreateFieldsOperation extends IUndoRedoOperationBase {
+ name: OperationName.CreateFields;
+ params: {
+ tableId: string;
+ };
+ result: {
+ fields: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[];
+ records?: {
+ id: string;
+ fields: Record;
+ }[];
+ };
+}
+
+export interface IDeleteFieldsOperation extends Omit {
+ name: OperationName.DeleteFields;
+}
+
+export interface IPasteSelectionOperation extends IUndoRedoOperationBase {
+ name: OperationName.PasteSelection;
+ params: {
+ tableId: string;
+ };
+ result: {
+ updateRecords?: {
+ recordIds: string[];
+ fieldIds: string[];
+ cellContexts: ICellContext[];
+ };
+ newFields?: (IFieldVo & { columnMeta?: IColumnMeta; references?: string[] })[];
+ newRecords?: (IRecord & { order?: Record })[];
+ };
+}
+
+export interface ICreateViewOperation extends IUndoRedoOperationBase {
+ name: OperationName.CreateView;
+ params: {
+ tableId: string;
+ };
+ result: {
+ view: IViewVo;
+ };
+}
+
+export interface IDeleteViewOperation extends IUndoRedoOperationBase {
+ name: OperationName.DeleteView;
+ params: {
+ tableId: string;
+ viewId: string;
+ };
+}
+
+export interface IUpdateViewOperation extends IUndoRedoOperationBase {
+ name: OperationName.UpdateView;
+ params: {
+ tableId: string;
+ viewId: string;
+ };
+ result: {
+ byKey?: {
+ key: IViewPropertyKeys;
+ newValue: unknown;
+ oldValue: unknown;
+ };
+ byOps?: IOtOperation[];
+ };
+}
+
+export type IUndoRedoOperation =
+ | IUpdateRecordsOperation
+ | ICreateRecordsOperation
+ | IDeleteRecordsOperation
+ | IUpdateRecordsOrderOperation
+ | ICreateFieldsOperation
+ | IDeleteFieldsOperation
+ | IConvertFieldOperation
+ | IConvertFieldV2Operation
+ | IPasteSelectionOperation
+ | ICreateViewOperation
+ | IDeleteViewOperation
+ | IUpdateViewOperation;
+export interface IPluginAuthStore {
+ baseId: string;
+ pluginId: string;
+}
diff --git a/apps/nestjs-backend/src/configs/auth.config.ts b/apps/nestjs-backend/src/configs/auth.config.ts
index 51e3c7b199..82ed3db752 100644
--- a/apps/nestjs-backend/src/configs/auth.config.ts
+++ b/apps/nestjs-backend/src/configs/auth.config.ts
@@ -3,25 +3,77 @@ import { Inject } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';
+const getCookieSecure = (value: string | undefined) => {
+ if (!value) {
+ return undefined;
+ }
+ if (value === 'auto') {
+ return 'auto' as const;
+ }
+ return value === 'true';
+};
+
export const authConfig = registerAs('auth', () => ({
jwt: {
- secret: process.env.BACKEND_JWT_SECRET ?? '533Cr3tK3yF0rH4sh1nGJ4W773k3n$',
+ secret:
+ process.env.BACKEND_JWT_SECRET ?? process.env.SECRET_KEY ?? '533Cr3tK3yF0rH4sh1nGJ4W773k3n$',
expiresIn: process.env.BACKEND_JWT_EXPIRES_IN ?? '20d',
},
session: {
secret:
process.env.BACKEND_SESSION_SECRET ??
+ process.env.SECRET_KEY ??
'dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932',
expiresIn: process.env.BACKEND_SESSION_EXPIRES_IN ?? '7d',
+ cookie: {
+ secure: getCookieSecure(process.env.BACKEND_SESSION_COOKIE_SECURE),
+ },
},
accessToken: {
- prefix: process.env.BRAND_NAME!.toLocaleLowerCase(),
+ prefix: 'teable',
encryption: {
algorithm: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_ALGORITHM ?? 'aes-128-cbc',
key: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx9',
iv: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf4',
},
},
+ resetPasswordEmailExpiresIn:
+ process.env.BACKEND_EMAIL_CODE_EXPIRES_IN ??
+ process.env.BACKEND_RESET_PASSWORD_EMAIL_EXPIRES_IN ??
+ '30m',
+ signupVerificationExpiresIn:
+ process.env.BACKEND_EMAIL_CODE_EXPIRES_IN ??
+ process.env.BACKEND_SIGNUP_VERIFICATION_EXPIRES_IN ??
+ '30m',
+ socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(',') ?? [],
+ github: {
+ clientID: process.env.BACKEND_GITHUB_CLIENT_ID,
+ clientSecret: process.env.BACKEND_GITHUB_CLIENT_SECRET,
+ callbackURL: process.env.BACKEND_GITHUB_CALLBACK_URL,
+ },
+ google: {
+ clientID: process.env.BACKEND_GOOGLE_CLIENT_ID,
+ clientSecret: process.env.BACKEND_GOOGLE_CLIENT_SECRET,
+ callbackURL: process.env.BACKEND_GOOGLE_CALLBACK_URL,
+ },
+ oidc: {
+ issuer: process.env.BACKEND_OIDC_ISSUER,
+ authorizationURL: process.env.BACKEND_OIDC_AUTHORIZATION_URL,
+ tokenURL: process.env.BACKEND_OIDC_TOKEN_URL,
+ userInfoURL: process.env.BACKEND_OIDC_USER_INFO_URL,
+ clientID: process.env.BACKEND_OIDC_CLIENT_ID,
+ clientSecret: process.env.BACKEND_OIDC_CLIENT_SECRET,
+ callbackURL: process.env.BACKEND_OIDC_CALLBACK_URL,
+ other: process.env.BACKEND_OIDC_OTHER ? JSON.parse(process.env.BACKEND_OIDC_OTHER) : {},
+ },
+ signin: {
+ maxLoginAttempts: process.env.SIGNIN_MAX_LOGIN_ATTEMPTS
+ ? Number(process.env.SIGNIN_MAX_LOGIN_ATTEMPTS)
+ : undefined,
+ accountLockoutMinutes: process.env.SIGNIN_ACCOUNT_LOCKOUT_MINUTES
+ ? Number(process.env.SIGNIN_ACCOUNT_LOCKOUT_MINUTES)
+ : undefined,
+ },
}));
export const AuthConfig = () => Inject(authConfig.KEY);
diff --git a/apps/nestjs-backend/src/configs/base.config.ts b/apps/nestjs-backend/src/configs/base.config.ts
index 8de8a85f5a..7743e7ea7c 100644
--- a/apps/nestjs-backend/src/configs/base.config.ts
+++ b/apps/nestjs-backend/src/configs/base.config.ts
@@ -4,14 +4,18 @@ import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';
export const baseConfig = registerAs('base', () => ({
- brandName: process.env.BRAND_NAME!,
- publicOrigin: process.env.PUBLIC_ORIGIN!,
- assetPrefix: process.env.ASSET_PREFIX ?? process.env.PUBLIC_ORIGIN!,
- storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN!,
+ isCloud: process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD',
+ publicOrigin: process.env.PUBLIC_ORIGIN,
+ storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN,
secretKey: process.env.SECRET_KEY ?? 'defaultSecretKey',
- publicDatabaseAddress: process.env.PUBLIC_DATABASE_ADDRESS,
- defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 3),
+ publicDatabaseProxy: process.env.PUBLIC_DATABASE_PROXY,
+ defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20),
templateSpaceId: process.env.TEMPLATE_SPACE_ID,
+ recordHistoryDisabled: process.env.RECORD_HISTORY_DISABLED === 'true',
+ pluginServerPort: process.env.PLUGIN_SERVER_PORT || '3002',
+ enableEmailCodeConsole: process.env.ENABLE_EMAIL_CODE_CONSOLE === 'true',
+ emailCodeExpiresIn: process.env.BACKEND_EMAIL_CODE_EXPIRES_IN ?? '30m',
+ chatContextAttachmentSize: Number(process.env.CHAT_CONTEXT_ATTACHMENT_SIZE ?? 10),
}));
export const BaseConfig = () => Inject(baseConfig.KEY);
diff --git a/apps/nestjs-backend/src/configs/config.module.ts b/apps/nestjs-backend/src/configs/config.module.ts
index 5e6d27ec9f..da5d899684 100644
--- a/apps/nestjs-backend/src/configs/config.module.ts
+++ b/apps/nestjs-backend/src/configs/config.module.ts
@@ -10,8 +10,10 @@ import { cacheConfig } from './cache.config';
import { envValidationSchema } from './env.validation.schema';
import { loggerConfig } from './logger.config';
import { mailConfig } from './mail.config';
+import { oauthConfig } from './oauth.config';
import { storageConfig } from './storage';
import { thresholdConfig } from './threshold.config';
+import { trashConfig } from './trash.config';
const configurations = [
...bootstrapConfigs,
@@ -22,6 +24,8 @@ const configurations = [
storageConfig,
thresholdConfig,
cacheConfig,
+ oauthConfig,
+ trashConfig,
];
@Module({})
diff --git a/apps/nestjs-backend/src/configs/env.validation.schema.ts b/apps/nestjs-backend/src/configs/env.validation.schema.ts
index 9c7eb5b04f..afc9bf9b7d 100644
--- a/apps/nestjs-backend/src/configs/env.validation.schema.ts
+++ b/apps/nestjs-backend/src/configs/env.validation.schema.ts
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/naming-convention */
import Joi from 'joi';
export const envValidationSchema = Joi.object({
@@ -14,12 +15,9 @@ export const envValidationSchema = Joi.object({
// database_url
PRISMA_DATABASE_URL: Joi.string().required(),
- ASSET_PREFIX: Joi.string().uri().optional(),
STORAGE_PREFIX: Joi.string().uri().optional(),
- PUBLIC_ORIGIN: Joi.string().uri(),
-
- BRAND_NAME: Joi.string().required(),
+ PUBLIC_ORIGIN: Joi.string().uri().required(),
// cache
BACKEND_CACHE_PROVIDER: Joi.string().valid('memory', 'sqlite', 'redis').default('sqlite'),
@@ -37,4 +35,25 @@ export const envValidationSchema = Joi.object({
.pattern(/^(redis:\/\/|rediss:\/\/)/)
.message('Cache `redis` the URI must start with the protocol `redis://` or `rediss://`'),
}),
+ // github auth
+ BACKEND_GITHUB_CLIENT_ID: Joi.when('SOCIAL_AUTH_PROVIDERS', {
+ is: Joi.string()
+ .regex(/(^|,)(github)(,|$)/)
+ .required(),
+ then: Joi.string().required().messages({
+ 'any.required':
+ 'The `BACKEND_GITHUB_CLIENT_ID` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',
+ }),
+ }),
+ BACKEND_GITHUB_CLIENT_SECRET: Joi.when('SOCIAL_AUTH_PROVIDERS', {
+ is: Joi.string()
+ .regex(/(^|,)(github)(,|$)/)
+ .required(),
+ then: Joi.string().required().messages({
+ 'any.required':
+ 'The `BACKEND_GITHUB_CLIENT_SECRET` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',
+ }),
+ }),
+
+ PASSWORD_LOGIN_DISABLED: Joi.string().equal('true').optional(),
});
diff --git a/apps/nestjs-backend/src/configs/mail.config.ts b/apps/nestjs-backend/src/configs/mail.config.ts
index 78b733cd1a..16adbf910e 100644
--- a/apps/nestjs-backend/src/configs/mail.config.ts
+++ b/apps/nestjs-backend/src/configs/mail.config.ts
@@ -3,18 +3,37 @@ import { Inject } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';
-export const mailConfig = registerAs('mail', () => ({
- origin: process.env.PUBLIC_ORIGIN ?? 'https://teable.io',
- host: process.env.BACKEND_MAIL_HOST ?? 'smtp.teable.io',
- port: parseInt(process.env.BACKEND_MAIL_PORT ?? '465', 10),
- secure: Object.is(process.env.BACKEND_MAIL_SECURE ?? 'true', 'true'),
- sender: process.env.BACKEND_MAIL_SENDER ?? 'noreply.teable.io',
- senderName: process.env.BACKEND_MAIL_SENDER_NAME ?? 'Teable',
- auth: {
- user: process.env.BACKEND_MAIL_AUTH_USER,
- pass: process.env.BACKEND_MAIL_AUTH_PASS,
- },
-}));
+export const mailConfig = registerAs('mail', () => {
+ const host = process.env.BACKEND_MAIL_HOST;
+ const authUser = process.env.BACKEND_MAIL_AUTH_USER;
+ const authPass = process.env.BACKEND_MAIL_AUTH_PASS;
+
+ // Check if mail is properly configured (host, user, and pass are all required)
+ const isConfigured = Boolean(host && authUser && authPass);
+
+ return {
+ origin: process.env.PUBLIC_ORIGIN ?? 'https://teable.ai',
+ host: host ?? 'smtp.teable.ai',
+ port: parseInt(process.env.BACKEND_MAIL_PORT ?? '465', 10),
+ secure: Object.is(process.env.BACKEND_MAIL_SECURE ?? 'true', 'true'),
+ sender: process.env.BACKEND_MAIL_SENDER ?? 'noreply.teable.ai',
+ senderName: process.env.BACKEND_MAIL_SENDER_NAME ?? 'Teable',
+ auth: {
+ user: authUser,
+ pass: authPass,
+ },
+ isConfigured,
+ connectionTimeout: parseInt(process.env.BACKEND_MAIL_CONNECTION_TIMEOUT ?? '10000', 10),
+ greetingTimeout: parseInt(process.env.BACKEND_MAIL_GREETING_TIMEOUT ?? '10000', 10),
+ dnsTimeout: parseInt(process.env.BACKEND_MAIL_DNS_TIMEOUT ?? '5000', 10),
+ encryption: {
+ algorithm: 'aes-128-cbc',
+ key: process.env.BACKEND_MAIL_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx1',
+ iv: process.env.BACKEND_MAIL_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf1',
+ encoding: 'base64' as BufferEncoding,
+ },
+ };
+});
export const MailConfig = () => Inject(mailConfig.KEY);
diff --git a/apps/nestjs-backend/src/configs/oauth.config.ts b/apps/nestjs-backend/src/configs/oauth.config.ts
new file mode 100644
index 0000000000..90225bf7a5
--- /dev/null
+++ b/apps/nestjs-backend/src/configs/oauth.config.ts
@@ -0,0 +1,18 @@
+import { Inject } from '@nestjs/common';
+import type { ConfigType } from '@nestjs/config';
+import { registerAs } from '@nestjs/config';
+
+export const oauthConfig = registerAs('oauth', () => ({
+ accessTokenExpireIn: process.env.BACKEND_OAUTH_ACCESS_TOKEN_EXPIRE_IN || '10m',
+ refreshTokenExpireIn: process.env.BACKEND_OAUTH_REFRESH_TOKEN_EXPIRE_IN || '30d',
+ transactionExpireIn: process.env.BACKEND_OAUTH_TRANSACTION_EXPIRE_IN || '5m',
+ codeExpireIn: process.env.BACKEND_OAUTH_CODE_EXPIRE_IN || '5m',
+ authorizedExpireIn: process.env.BACKEND_OAUTH_AUTHORIZED_EXPIRE_IN || '7d',
+ tokenRateLimit: Number(process.env.BACKEND_OAUTH_TOKEN_RATE_LIMIT || 30),
+ tokenRateWindow: process.env.BACKEND_OAUTH_TOKEN_RATE_WINDOW || '15m',
+}));
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const OAuthConfig = () => Inject(oauthConfig.KEY);
+
+export type IOAuthConfig = ConfigType;
diff --git a/apps/nestjs-backend/src/configs/storage.ts b/apps/nestjs-backend/src/configs/storage.ts
index feeb9abd6d..4106347735 100644
--- a/apps/nestjs-backend/src/configs/storage.ts
+++ b/apps/nestjs-backend/src/configs/storage.ts
@@ -4,18 +4,35 @@ import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';
export const storageConfig = registerAs('storage', () => ({
- provider: (process.env.BACKEND_STORAGE_PROVIDER ?? 'local') as 'local' | 'minio',
+ provider: (process.env.BACKEND_STORAGE_PROVIDER ?? 'local') as
+ | 'local'
+ | 'minio'
+ | 's3'
+ | 'aliyun',
local: {
path: process.env.BACKEND_STORAGE_LOCAL_PATH ?? '.assets/uploads',
},
+ publicUrl: process.env.BACKEND_STORAGE_PUBLIC_URL,
publicBucket: process.env.BACKEND_STORAGE_PUBLIC_BUCKET || 'public',
privateBucket: process.env.BACKEND_STORAGE_PRIVATE_BUCKET || 'private',
+ privateBucketEndpoint: process.env.BACKEND_STORAGE_PRIVATE_BUCKET_ENDPOINT,
minio: {
endPoint: process.env.BACKEND_STORAGE_MINIO_ENDPOINT,
+ internalEndPoint: process.env.BACKEND_STORAGE_MINIO_INTERNAL_ENDPOINT,
+ internalPort: Number(process.env.BACKEND_STORAGE_MINIO_INTERNAL_PORT ?? 9000),
port: Number(process.env.BACKEND_STORAGE_MINIO_PORT ?? 9000),
useSSL: process.env.BACKEND_STORAGE_MINIO_USE_SSL === 'true',
accessKey: process.env.BACKEND_STORAGE_MINIO_ACCESS_KEY,
secretKey: process.env.BACKEND_STORAGE_MINIO_SECRET_KEY,
+ region: process.env.BACKEND_STORAGE_MINIO_REGION,
+ },
+ s3: {
+ region: process.env.BACKEND_STORAGE_S3_REGION!,
+ endpoint: process.env.BACKEND_STORAGE_S3_ENDPOINT,
+ internalEndpoint: process.env.BACKEND_STORAGE_S3_INTERNAL_ENDPOINT,
+ accessKey: process.env.BACKEND_STORAGE_S3_ACCESS_KEY!,
+ secretKey: process.env.BACKEND_STORAGE_S3_SECRET_KEY!,
+ maxSockets: Number(process.env.BACKEND_STORAGE_S3_MAX_SOCKETS ?? 100),
},
uploadMethod: process.env.BACKEND_STORAGE_UPLOAD_METHOD ?? 'put',
encryption: {
@@ -23,8 +40,9 @@ export const storageConfig = registerAs('storage', () => ({
key: process.env.BACKEND_STORAGE_ENCRYPTION_KEY ?? '73b00476e456323e',
iv: process.env.BACKEND_STORAGE_ENCRYPTION_IV ?? '8c9183e4c175f63c',
},
- tokenExpireIn: process.env.BACKEND_STORAGE_TOKEN_EXPIRE_IN ?? '7d',
- urlExpireIn: process.env.BACKEND_STORAGE_URL_EXPIRE_IN ?? '7d',
+ // must be less than 7 days
+ tokenExpireIn: process.env.BACKEND_STORAGE_TOKEN_EXPIRE_IN ?? '6d',
+ urlExpireIn: process.env.BACKEND_STORAGE_URL_EXPIRE_IN ?? '6d',
}));
export const StorageConfig = () => Inject(storageConfig.KEY);
diff --git a/apps/nestjs-backend/src/configs/threshold.config.ts b/apps/nestjs-backend/src/configs/threshold.config.ts
index 39d30018af..b6a017c0d2 100644
--- a/apps/nestjs-backend/src/configs/threshold.config.ts
+++ b/apps/nestjs-backend/src/configs/threshold.config.ts
@@ -1,3 +1,4 @@
+/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable @typescript-eslint/naming-convention */
import { Inject } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
@@ -5,17 +6,51 @@ import { registerAs } from '@nestjs/config';
export const thresholdConfig = registerAs('threshold', () => ({
maxCopyCells: Number(process.env.MAX_COPY_CELLS ?? 50_000),
- maxResetCells: Number(process.env.MAX_RESET_CELLS ?? 10_000),
- maxPasteCells: Number(process.env.MAX_PASTE_CELLS ?? 10_000),
+ maxResetCells: Number(process.env.MAX_RESET_CELLS ?? 50_000),
+ maxPasteCells: Number(process.env.MAX_PASTE_CELLS ?? 50_000),
maxReadRows: Number(process.env.MAX_READ_ROWS ?? 10_000),
maxDeleteRows: Number(process.env.MAX_DELETE_ROWS ?? 1_000),
maxSyncUpdateCells: Number(process.env.MAX_SYNC_UPDATE_CELLS ?? 10_000),
maxGroupPoints: Number(process.env.MAX_GROUP_POINTS ?? 5_000),
calcChunkSize: Number(process.env.CALC_CHUNK_SIZE ?? 1_000),
+ maxFreeRowLimit: Number(process.env.MAX_FREE_ROW_LIMIT ?? 0),
estimateCalcCelPerMs: Number(process.env.ESTIMATE_CALC_CEL_PER_MS ?? 3),
+ maxUndoStackSize: Number(process.env.MAX_UNDO_STACK_SIZE ?? 200),
+ undoExpirationTime: Number(process.env.UNDO_EXPIRATION_TIME ?? 86400),
bigTransactionTimeout: Number(
process.env.BIG_TRANSACTION_TIMEOUT ?? 10 * 60 * 1000 /* 10 mins */
),
+ automationGap: Number(process.env.AUTOMATION_GAP ?? 200),
+ maxAttachmentUploadSize: Number(process.env.MAX_ATTACHMENT_UPLOAD_SIZE ?? Infinity),
+ maxOpenapiAttachmentUploadSize: Number(
+ process.env.MAX_OPENAPI_ATTACHMENT_UPLOAD_SIZE ?? Infinity
+ ),
+ webhook: {
+ bodyLimitBytes: Number(process.env.WEBHOOK_BODY_LIMIT_BYTES ?? 4 * 1024 * 1024),
+ baseRateLimit: Number(process.env.WEBHOOK_BASE_RATE_LIMIT ?? 50),
+ workflowRateLimit: Number(process.env.WEBHOOK_WORKFLOW_RATE_LIMIT ?? 2),
+ },
+ dbDeadlock: {
+ maxRetries: Number(process.env.BACKEND_DB_DEADLOCK_MAX_RETRIES ?? 3),
+ initialBackoff: Number(process.env.BACKEND_DB_DEADLOCK_INITIAL_BACKOFF ?? 100),
+ jitter: Number(process.env.BACKEND_DB_DEADLOCK_JITTER ?? 1.0),
+ },
+ baseNodeMaxFolderDepth: Number(process.env.BASE_NODE_MAX_FOLDER_DEPTH ?? 2),
+ changeEmailSendCodeMailRate: Number(process.env.BACKEND_CHANGE_EMAIL_SEND_CODE_MAIL_RATE ?? 30),
+ resetPasswordSendMailRate: Number(process.env.BACKEND_RESET_PASSWORD_SEND_MAIL_RATE ?? 30),
+ signupVerificationSendCodeMailRate: Number(
+ process.env.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS ??
+ process.env.BACKEND_SIGNUP_VERIFICATION_SEND_CODE_MAIL_RATE ??
+ 30
+ ),
+ billing: {
+ automationRunGracePeriod: process.env.BILLING_AUTOMATION_RUN_GRACE_PERIOD ?? '3d',
+ automationRunNotifyInterval: process.env.BILLING_AUTOMATION_RUN_NOTIFY_INTERVAL ?? '6h',
+ },
+ automation: {
+ maxEmailsPerPoll: Number(process.env.AUTOMATION_MAX_EMAILS_PER_POLL ?? 100),
+ maxEmailDedupWindowSize: Number(process.env.AUTOMATION_MAX_EMAIL_DEDUP_WINDOW_SIZE ?? 500),
+ },
}));
export const ThresholdConfig = () => Inject(thresholdConfig.KEY);
diff --git a/apps/nestjs-backend/src/configs/trash.config.ts b/apps/nestjs-backend/src/configs/trash.config.ts
new file mode 100644
index 0000000000..2cac9e5f22
--- /dev/null
+++ b/apps/nestjs-backend/src/configs/trash.config.ts
@@ -0,0 +1,26 @@
+/* eslint-disable sonarjs/cognitive-complexity */
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import { Inject } from '@nestjs/common';
+import { registerAs } from '@nestjs/config';
+import ms from 'ms';
+
+export const trashConfig = registerAs('trash', () => ({
+ /**
+ * Retention period for trashed resources before permanent deletion.
+ * Supports ms library format: '30d', '7d', '24h', etc.
+ * Set to '0' to disable automatic cleanup.
+ * Default: 30 days
+ */
+ retention: ms((process.env.TRASH_RETENTION as string) ?? '30d'),
+ /**
+ * Interval between trash cleanup scans.
+ * Supports ms library format: '1h', '30m', '2d', etc.
+ * Default: 1 hour
+ */
+ scanInterval: ms((process.env.TRASH_SCAN_INTERVAL as string) ?? '1h'),
+}));
+
+export const TrashConfig = () => Inject(trashConfig.KEY);
+
+export type ITrashConfig = ReturnType;
diff --git a/apps/nestjs-backend/src/custom.exception.ts b/apps/nestjs-backend/src/custom.exception.ts
index 42d704ef6b..83be7c22bf 100644
--- a/apps/nestjs-backend/src/custom.exception.ts
+++ b/apps/nestjs-backend/src/custom.exception.ts
@@ -1,12 +1,21 @@
import { HttpException, HttpStatus } from '@nestjs/common';
+import type { ICustomHttpExceptionData } from '@teable/core';
import { ErrorCodeToStatusMap, HttpErrorCode } from '@teable/core';
+import type { Path } from 'nestjs-i18n';
+import type { I18nTranslations } from './types/i18n.generated';
export class CustomHttpException extends HttpException {
code: string;
+ data?: ICustomHttpExceptionData;
- constructor(message: string, code: HttpErrorCode) {
+ constructor(
+ message: string,
+ code: HttpErrorCode,
+ data?: ICustomHttpExceptionData>
+ ) {
super(message, ErrorCodeToStatusMap[code]);
this.code = code;
+ this.data = data;
}
}
@@ -16,15 +25,38 @@ export const getDefaultCodeByStatus = (status: HttpStatus) => {
return HttpErrorCode.VALIDATION_ERROR;
case HttpStatus.UNAUTHORIZED:
return HttpErrorCode.UNAUTHORIZED;
+ case HttpStatus.PAYMENT_REQUIRED:
+ return HttpErrorCode.PAYMENT_REQUIRED;
case HttpStatus.FORBIDDEN:
return HttpErrorCode.RESTRICTED_RESOURCE;
case HttpStatus.NOT_FOUND:
return HttpErrorCode.NOT_FOUND;
+ case HttpStatus.CONFLICT:
+ return HttpErrorCode.CONFLICT;
case HttpStatus.INTERNAL_SERVER_ERROR:
return HttpErrorCode.INTERNAL_SERVER_ERROR;
case HttpStatus.SERVICE_UNAVAILABLE:
return HttpErrorCode.DATABASE_CONNECTION_UNAVAILABLE;
+ case HttpStatus.REQUEST_TIMEOUT:
+ return HttpErrorCode.REQUEST_TIMEOUT;
+ case HttpStatus.TOO_MANY_REQUESTS:
+ return HttpErrorCode.TOO_MANY_REQUESTS;
+ case HttpStatus.PAYLOAD_TOO_LARGE:
+ return HttpErrorCode.PAYLOAD_TOO_LARGE;
+ case HttpStatus.GATEWAY_TIMEOUT:
+ return HttpErrorCode.GATEWAY_TIMEOUT;
default:
return HttpErrorCode.UNKNOWN_ERROR_CODE;
}
};
+
+export class TemplateAppTokenNotAllowedException extends HttpException {
+ constructor() {
+ super(
+ {
+ message: 'Template preview app token operation not allowed',
+ },
+ 200
+ );
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.abstract.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.abstract.ts
index 8ba198aacd..808c0a8228 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.abstract.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.abstract.ts
@@ -1,26 +1,39 @@
-import { InternalServerErrorException, Logger } from '@nestjs/common';
+import { InternalServerErrorException } from '@nestjs/common';
+import type { FieldCore } from '@teable/core';
import { StatisticsFunc } from '@teable/core';
import type { Knex } from 'knex';
-import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryAggregateContext } from '../../features/record/query-builder/record-query-builder.interface';
import type { IAggregationFunctionInterface } from './aggregation-function.interface';
export abstract class AbstractAggregationFunction implements IAggregationFunctionInterface {
- private logger = new Logger(AbstractAggregationFunction.name);
-
protected tableColumnRef: string;
constructor(
protected readonly knex: Knex,
- protected readonly dbTableName: string,
- protected readonly field: IFieldInstance
+ protected readonly field: FieldCore,
+ readonly context?: IRecordQueryAggregateContext
) {
- const { dbFieldName } = this.field;
+ const { dbFieldName, id } = field;
+
+ const selection = context?.selectionMap.get(id);
+ if (selection) {
+ this.tableColumnRef = selection as string;
+ } else {
+ this.tableColumnRef = dbFieldName;
+ }
+ }
- this.tableColumnRef = `${this.dbTableName}.${dbFieldName}`;
+ get dbTableName() {
+ return this.context?.tableDbName;
}
- compiler(builderClient: Knex.QueryBuilder, aggFunc: StatisticsFunc) {
+ get tableAlias() {
+ return this.context?.tableAlias;
+ }
+
+ compiler(builderClient: Knex.QueryBuilder, aggFunc: StatisticsFunc, alias: string | undefined) {
const functionHandlers = {
+ [StatisticsFunc.Count]: this.count,
[StatisticsFunc.Empty]: this.empty,
[StatisticsFunc.Filled]: this.filled,
[StatisticsFunc.Unique]: this.unique,
@@ -52,6 +65,7 @@ export abstract class AbstractAggregationFunction implements IAggregationFunctio
let rawSql: string = chosenHandler();
const ignoreMcvFunc = [
+ StatisticsFunc.Count,
StatisticsFunc.Empty,
StatisticsFunc.UnChecked,
StatisticsFunc.Filled,
@@ -60,6 +74,8 @@ export abstract class AbstractAggregationFunction implements IAggregationFunctio
StatisticsFunc.PercentUnChecked,
StatisticsFunc.PercentFilled,
StatisticsFunc.PercentChecked,
+ // Special-case: compute per-row then sum across group without MCV join
+ StatisticsFunc.TotalAttachmentSize,
];
if (isMultipleCellValue && !ignoreMcvFunc.includes(aggFunc)) {
@@ -71,35 +87,41 @@ export abstract class AbstractAggregationFunction implements IAggregationFunctio
rawSql = `MAX(${this.knex.ref(`${joinTable}.value`)})`;
}
- return builderClient.select(this.knex.raw(`${rawSql} AS ??`, [`${fieldId}_${aggFunc}`]));
+ return builderClient.select(
+ this.knex.raw(`${rawSql} AS ??`, [alias ?? `${fieldId}_${aggFunc}`])
+ );
+ }
+
+ count(): string {
+ return this.knex.raw(`COUNT(*)`).toQuery();
}
empty(): string {
- return this.knex.raw(`COUNT(*) - COUNT(??)`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`COUNT(*) - COUNT(${this.tableColumnRef})`).toQuery();
}
filled(): string {
- return this.knex.raw(`COUNT(??)`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`COUNT(${this.tableColumnRef})`).toQuery();
}
unique(): string {
- return this.knex.raw(`COUNT(DISTINCT ??)`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`COUNT(DISTINCT ${this.tableColumnRef})`).toQuery();
}
max(): string {
- return this.knex.raw(`MAX(??)`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`MAX(${this.tableColumnRef})`).toQuery();
}
min(): string {
- return this.knex.raw(`MIN(??)`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`MIN(${this.tableColumnRef})`).toQuery();
}
sum(): string {
- return this.knex.raw(`SUM(??)`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`SUM(${this.tableColumnRef})`).toQuery();
}
average(): string {
- return this.knex.raw(`AVG(??)`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`AVG(${this.tableColumnRef})`).toQuery();
}
checked(): string {
@@ -110,29 +132,15 @@ export abstract class AbstractAggregationFunction implements IAggregationFunctio
return this.empty();
}
- percentEmpty(): string {
- return this.knex
- .raw(`((COUNT(*) - COUNT(??)) * 1.0 / COUNT(*)) * 100`, [this.tableColumnRef])
- .toQuery();
- }
+ abstract percentEmpty(): string;
- percentFilled(): string {
- return this.knex.raw(`(COUNT(??) * 1.0 / COUNT(*)) * 100`, [this.tableColumnRef]).toQuery();
- }
+ abstract percentFilled(): string;
- percentUnique(): string {
- return this.knex
- .raw(`(COUNT(DISTINCT ??) * 1.0 / COUNT(*)) * 100`, [this.tableColumnRef])
- .toQuery();
- }
+ abstract percentUnique(): string;
- percentChecked(): string {
- return this.percentFilled();
- }
+ abstract percentChecked(): string;
- percentUnChecked(): string {
- return this.percentEmpty();
- }
+ abstract percentUnChecked(): string;
earliestDate(): string {
return this.min();
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.interface.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.interface.ts
index 0d26ff242f..94e87c27aa 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.interface.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-function.interface.ts
@@ -1,6 +1,7 @@
export type IAggregationFunctionHandler = () => string;
export interface IAggregationFunctionInterface {
+ count: IAggregationFunctionHandler;
empty: IAggregationFunctionHandler;
filled: IAggregationFunctionHandler;
unique: IAggregationFunctionHandler;
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.abstract.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.abstract.ts
index 1969d5eaac..b99a59553f 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.abstract.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.abstract.ts
@@ -1,68 +1,115 @@
-import { BadRequestException, Logger } from '@nestjs/common';
-import type { IAggregationField } from '@teable/core';
-import { CellValueType, DbFieldType, getValidStatisticFunc } from '@teable/core';
+import { BadRequestException } from '@nestjs/common';
+import type { FieldCore } from '@teable/core';
+import { CellValueType, DbFieldType, getValidStatisticFunc, StatisticsFunc } from '@teable/core';
+import type { IAggregationField } from '@teable/openapi';
import type { Knex } from 'knex';
-import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryAggregateContext } from '../../features/record/query-builder/record-query-builder.interface';
import type { IAggregationQueryExtra } from '../db.provider.interface';
import type { AbstractAggregationFunction } from './aggregation-function.abstract';
import type { IAggregationQueryInterface } from './aggregation-query.interface';
export abstract class AbstractAggregationQuery implements IAggregationQueryInterface {
- private logger = new Logger(AbstractAggregationQuery.name);
-
constructor(
protected readonly knex: Knex,
protected readonly originQueryBuilder: Knex.QueryBuilder,
- protected readonly dbTableName: string,
- protected readonly fields?: { [fieldId: string]: IFieldInstance },
+ protected readonly fields?: { [fieldId: string]: FieldCore },
protected readonly aggregationFields?: IAggregationField[],
- protected readonly extra?: IAggregationQueryExtra
+ protected readonly extra?: IAggregationQueryExtra,
+ protected readonly context?: IRecordQueryAggregateContext
) {}
- toQuerySql(): string {
+ get dbTableName() {
+ return this.context?.tableDbName;
+ }
+
+ get tableAlias() {
+ return this.context?.tableAlias;
+ }
+
+ appendBuilder(): Knex.QueryBuilder {
const queryBuilder = this.originQueryBuilder;
if (!this.aggregationFields || !this.aggregationFields.length) {
- return queryBuilder.toQuery();
+ return queryBuilder;
}
this.validAggregationField(this.aggregationFields, this.extra);
- this.aggregationFields.forEach(({ fieldId, statisticFunc }) => {
+ this.aggregationFields.forEach(({ fieldId, statisticFunc, alias }) => {
+ // TODO: handle all func type
+ if (statisticFunc === StatisticsFunc.Count && fieldId === '*') {
+ const field = Object.values(this.fields ?? {})[0];
+ if (!field) {
+ return queryBuilder;
+ }
+ this.getAggregationAdapter(field).compiler(queryBuilder, statisticFunc, alias);
+ return;
+ }
const field = this.fields && this.fields[fieldId];
if (!field) {
return queryBuilder;
}
- this.getAggregationAdapter(field).compiler(queryBuilder, statisticFunc);
+ this.getAggregationAdapter(field).compiler(queryBuilder, statisticFunc, alias);
});
- const aggSql = queryBuilder.toQuery();
- this.logger.debug('toQuerySql AggSql: %s', aggSql);
- return aggSql;
+ // Emit GROUP BY and grouped select columns when requested via extra.groupBy
+ if (this.extra?.groupBy && this.extra.groupBy.length > 0) {
+ const groupByExprs = this.extra.groupBy
+ .map((fieldId) => {
+ const mapped = this.context?.selectionMap.get(fieldId) as string | undefined;
+ if (mapped) return mapped;
+ const dbFieldName = this.fields?.[fieldId]?.dbFieldName;
+ if (!dbFieldName) return null;
+ return this.tableAlias ? `"${this.tableAlias}"."${dbFieldName}"` : `"${dbFieldName}"`;
+ })
+ .filter(Boolean) as string[];
+
+ for (const expr of groupByExprs) {
+ queryBuilder.groupByRaw(expr);
+ }
+
+ for (const fieldId of this.extra.groupBy) {
+ const field = this.fields?.[fieldId];
+ if (!field) continue;
+ const mapped =
+ (this.context?.selectionMap.get(fieldId) as string | undefined) ??
+ (this.tableAlias
+ ? `"${this.tableAlias}"."${field.dbFieldName}"`
+ : `"${field.dbFieldName}"`);
+ queryBuilder.select(this.knex.raw(`${mapped} AS ??`, [field.dbFieldName]));
+ }
+
+ // Ensure no stray ORDER BY (e.g., inherited from view default sort) remains after grouping
+ queryBuilder.clearOrder();
+ }
+
+ return queryBuilder;
}
private validAggregationField(
aggregationFields: IAggregationField[],
_extra?: IAggregationQueryExtra
) {
- aggregationFields.forEach(({ fieldId, statisticFunc }) => {
- const field = this.fields && this.fields[fieldId];
+ aggregationFields
+ .filter(({ fieldId }) => !!fieldId && fieldId !== '*')
+ .forEach(({ fieldId, statisticFunc }) => {
+ const field = this.fields && this.fields[fieldId];
- if (!field) {
- throw new BadRequestException(`field: '${fieldId}' is invalid`);
- }
+ if (!field) {
+ throw new BadRequestException(`field: '${fieldId}' is invalid`);
+ }
- const validStatisticFunc = getValidStatisticFunc(field);
- if (statisticFunc && !validStatisticFunc.includes(statisticFunc)) {
- throw new BadRequestException(
- `field: '${fieldId}', aggregation func: '${statisticFunc}' is invalid, Only the following func are allowed: [${validStatisticFunc}]`
- );
- }
- });
+ const validStatisticFunc = getValidStatisticFunc(field);
+ if (statisticFunc && !validStatisticFunc.includes(statisticFunc)) {
+ throw new BadRequestException(
+ `field: '${fieldId}', aggregation func: '${statisticFunc}' is invalid, Only the following func are allowed: [${validStatisticFunc}]`
+ );
+ }
+ });
}
- private getAggregationAdapter(field: IFieldInstance): AbstractAggregationFunction {
+ private getAggregationAdapter(field: FieldCore): AbstractAggregationFunction {
const { dbFieldType } = field;
switch (field.cellValueType) {
case CellValueType.Boolean:
@@ -80,13 +127,13 @@ export abstract class AbstractAggregationQuery implements IAggregationQueryInter
}
}
- abstract booleanAggregation(field: IFieldInstance): AbstractAggregationFunction;
+ abstract booleanAggregation(field: FieldCore): AbstractAggregationFunction;
- abstract numberAggregation(field: IFieldInstance): AbstractAggregationFunction;
+ abstract numberAggregation(field: FieldCore): AbstractAggregationFunction;
- abstract dateTimeAggregation(field: IFieldInstance): AbstractAggregationFunction;
+ abstract dateTimeAggregation(field: FieldCore): AbstractAggregationFunction;
- abstract stringAggregation(field: IFieldInstance): AbstractAggregationFunction;
+ abstract stringAggregation(field: FieldCore): AbstractAggregationFunction;
- abstract jsonAggregation(field: IFieldInstance): AbstractAggregationFunction;
+ abstract jsonAggregation(field: FieldCore): AbstractAggregationFunction;
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.interface.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.interface.ts
index 592b7c715e..d922c4d06a 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.interface.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/aggregation-query.interface.ts
@@ -1,3 +1,5 @@
+import type { Knex } from 'knex';
+
export interface IAggregationQueryInterface {
- toQuerySql(): string;
+ appendBuilder(): Knex.QueryBuilder;
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/__tests__/multiple-value-aggregation.adapter.spec.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/__tests__/multiple-value-aggregation.adapter.spec.ts
new file mode 100644
index 0000000000..ce053f2e3b
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/__tests__/multiple-value-aggregation.adapter.spec.ts
@@ -0,0 +1,40 @@
+import type { FieldCore } from '@teable/core';
+import { FieldType } from '@teable/core';
+import knex from 'knex';
+import { describe, expect, it } from 'vitest';
+import type { IRecordQueryAggregateContext } from '../../../../features/record/query-builder/record-query-builder.interface';
+import { MultipleValueAggregationAdapter } from '../multiple-value/multiple-value-aggregation.adapter';
+
+const knexClient = knex({ client: 'pg' });
+
+const createAdapter = () => {
+ const field = {
+ id: 'fldNumericArray',
+ dbFieldName: '"values"',
+ isMultipleCellValue: true,
+ type: FieldType.Number,
+ } as unknown as FieldCore;
+
+ const context: IRecordQueryAggregateContext = {
+ selectionMap: new Map([[field.id, '"alias"."values"']]),
+ tableDbName: 'public.test_table',
+ tableAlias: 'alias',
+ };
+
+ return new MultipleValueAggregationAdapter(knexClient, field, context);
+};
+
+describe('MultipleValueAggregationAdapter numeric coercion', () => {
+ it.each([
+ ['sum', (adapter: MultipleValueAggregationAdapter) => adapter.sum()],
+ ['average', (adapter: MultipleValueAggregationAdapter) => adapter.average()],
+ ['max', (adapter: MultipleValueAggregationAdapter) => adapter.max()],
+ ['min', (adapter: MultipleValueAggregationAdapter) => adapter.min()],
+ ])('renders %s aggregation without integer casts', (_, getSql) => {
+ const adapter = createAdapter();
+ const sql = getSql(adapter);
+ expect(sql).toContain('::double precision');
+ expect(sql).toContain('REGEXP_REPLACE');
+ expect(sql.toUpperCase()).not.toContain('::INTEGER');
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-function.postgres.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-function.postgres.ts
index e00cd79857..3015de3d22 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-function.postgres.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-function.postgres.ts
@@ -5,21 +5,29 @@ import { AbstractAggregationFunction } from '../aggregation-function.abstract';
export class AggregationFunctionPostgres extends AbstractAggregationFunction {
unique(): string {
const { type, isMultipleCellValue } = this.field;
- if (type !== FieldType.User || isMultipleCellValue) {
+ if (
+ ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||
+ isMultipleCellValue
+ ) {
return super.unique();
}
- return this.knex.raw(`COUNT(DISTINCT ?? ->> 'id')`, [this.tableColumnRef]).toQuery();
+ return this.knex.raw(`COUNT(DISTINCT ${this.tableColumnRef} ->> 'id')`).toQuery();
}
percentUnique(): string {
const { type, isMultipleCellValue } = this.field;
- if (type !== FieldType.User || isMultipleCellValue) {
- return super.percentUnique();
+ if (
+ ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||
+ isMultipleCellValue
+ ) {
+ return this.knex
+ .raw(`(COUNT(DISTINCT ${this.tableColumnRef}) * 1.0 / GREATEST(COUNT(*), 1)) * 100`)
+ .toQuery();
}
return this.knex
- .raw(`(COUNT(DISTINCT ?? ->> 'id') * 1.0 / COUNT(*)) * 100`, [this.tableColumnRef])
+ .raw(`(COUNT(DISTINCT ${this.tableColumnRef} ->> 'id') * 1.0 / GREATEST(COUNT(*), 1)) * 100`)
.toQuery();
}
@@ -32,10 +40,53 @@ export class AggregationFunctionPostgres extends AbstractAggregationFunction {
}
totalAttachmentSize(): string {
+ // Sum sizes per row, then sum across the current scope (respects GROUP BY)
return this.knex
.raw(
- `SELECT SUM(("value"::json ->> 'size')::INTEGER) AS "value" FROM ??, jsonb_array_elements(??)`,
- [this.dbTableName, this.tableColumnRef]
+ `SUM(COALESCE((SELECT SUM((e.value ->> 'size')::INTEGER)
+ FROM jsonb_array_elements(COALESCE(${this.tableColumnRef}, '[]'::jsonb)) AS e), 0))`
+ )
+ .toQuery();
+ }
+
+ percentEmpty(): string {
+ return this.knex
+ .raw(`((COUNT(*) - COUNT(${this.tableColumnRef})) * 1.0 / GREATEST(COUNT(*), 1)) * 100`)
+ .toQuery();
+ }
+
+ percentFilled(): string {
+ return this.knex
+ .raw(`(COUNT(${this.tableColumnRef}) * 1.0 / GREATEST(COUNT(*), 1)) * 100`)
+ .toQuery();
+ }
+
+ checked(): string {
+ return this.knex
+ .raw(`SUM(CASE WHEN ${this.tableColumnRef} = true THEN 1 ELSE 0 END)`)
+ .toQuery();
+ }
+
+ unChecked(): string {
+ return this.knex
+ .raw(
+ `SUM(CASE WHEN ${this.tableColumnRef} = false OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END)`
+ )
+ .toQuery();
+ }
+
+ percentChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN ${this.tableColumnRef} = true THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`
+ )
+ .toQuery();
+ }
+
+ percentUnChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN ${this.tableColumnRef} = false OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`
)
.toQuery();
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-query.postgres.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-query.postgres.ts
index d03cce07d9..6c2ff900e2 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-query.postgres.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/aggregation-query.postgres.ts
@@ -1,35 +1,35 @@
-import type { IFieldInstance } from '../../../features/field/model/factory';
+import type { FieldCore } from '@teable/core';
import { AbstractAggregationQuery } from '../aggregation-query.abstract';
import type { AggregationFunctionPostgres } from './aggregation-function.postgres';
import { MultipleValueAggregationAdapter } from './multiple-value/multiple-value-aggregation.adapter';
import { SingleValueAggregationAdapter } from './single-value/single-value-aggregation.adapter';
export class AggregationQueryPostgres extends AbstractAggregationQuery {
- private coreAggregation(field: IFieldInstance): AggregationFunctionPostgres {
+ private coreAggregation(field: FieldCore): AggregationFunctionPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleValueAggregationAdapter(this.knex, this.dbTableName, field);
+ return new MultipleValueAggregationAdapter(this.knex, field, this.context);
}
- return new SingleValueAggregationAdapter(this.knex, this.dbTableName, field);
+ return new SingleValueAggregationAdapter(this.knex, field, this.context);
}
- booleanAggregation(field: IFieldInstance): AggregationFunctionPostgres {
+ booleanAggregation(field: FieldCore): AggregationFunctionPostgres {
return this.coreAggregation(field);
}
- numberAggregation(field: IFieldInstance): AggregationFunctionPostgres {
+ numberAggregation(field: FieldCore): AggregationFunctionPostgres {
return this.coreAggregation(field);
}
- dateTimeAggregation(field: IFieldInstance): AggregationFunctionPostgres {
+ dateTimeAggregation(field: FieldCore): AggregationFunctionPostgres {
return this.coreAggregation(field);
}
- stringAggregation(field: IFieldInstance): AggregationFunctionPostgres {
+ stringAggregation(field: FieldCore): AggregationFunctionPostgres {
return this.coreAggregation(field);
}
- jsonAggregation(field: IFieldInstance): AggregationFunctionPostgres {
+ jsonAggregation(field: FieldCore): AggregationFunctionPostgres {
return this.coreAggregation(field);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/multiple-value/multiple-value-aggregation.adapter.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/multiple-value/multiple-value-aggregation.adapter.ts
index 99ff142015..1e7c06857e 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/multiple-value/multiple-value-aggregation.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/multiple-value/multiple-value-aggregation.adapter.ts
@@ -1,11 +1,17 @@
import { AggregationFunctionPostgres } from '../aggregation-function.postgres';
export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres {
+ private toNumericSafe(columnExpression: string): string {
+ const textExpr = `(${columnExpression})::text`;
+ const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;
+ return `NULLIF(${sanitized}, '')::double precision`;
+ }
+
unique(): string {
return this.knex
.raw(
- `SELECT COUNT(DISTINCT "value") AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT COUNT(DISTINCT "value") AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
)
.toQuery();
}
@@ -13,8 +19,8 @@ export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres
max(): string {
return this.knex
.raw(
- `SELECT MAX("value"::INTEGER) AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT MAX(${this.toNumericSafe('"value"')}) AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
)
.toQuery();
}
@@ -22,8 +28,8 @@ export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres
min(): string {
return this.knex
.raw(
- `SELECT MIN("value"::INTEGER) AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT MIN(${this.toNumericSafe('"value"')}) AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
)
.toQuery();
}
@@ -31,8 +37,8 @@ export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres
sum(): string {
return this.knex
.raw(
- `SELECT SUM("value"::INTEGER) AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT SUM(${this.toNumericSafe('"value"')}) AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
)
.toQuery();
}
@@ -40,8 +46,8 @@ export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres
average(): string {
return this.knex
.raw(
- `SELECT AVG("value"::INTEGER) AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT AVG(${this.toNumericSafe('"value"')}) AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
)
.toQuery();
}
@@ -49,8 +55,8 @@ export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres
percentUnique(): string {
return this.knex
.raw(
- `SELECT (COUNT(DISTINCT "value") * 1.0 / COUNT(*)) * 100 AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT (COUNT(DISTINCT "value") * 1.0 / GREATEST(COUNT(*), 1)) * 100 AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
)
.toQuery();
}
@@ -58,8 +64,8 @@ export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres
dateRangeOfDays(): string {
return this.knex
.raw(
- `SELECT extract(DAY FROM (MAX("value"::TIMESTAMPTZ) - MIN("value"::TIMESTAMPTZ)))::INTEGER AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT extract(DAY FROM (MAX("value"::TIMESTAMPTZ) - MIN("value"::TIMESTAMPTZ)))::INTEGER AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
)
.toQuery();
}
@@ -67,8 +73,38 @@ export class MultipleValueAggregationAdapter extends AggregationFunctionPostgres
dateRangeOfMonths(): string {
return this.knex
.raw(
- `SELECT CONCAT(MAX("value"::TIMESTAMPTZ), ',', MIN("value"::TIMESTAMPTZ)) AS "value" FROM ??, jsonb_array_elements_text(??::jsonb)`,
- [this.dbTableName, this.tableColumnRef]
+ `SELECT CONCAT(MAX("value"::TIMESTAMPTZ), ',', MIN("value"::TIMESTAMPTZ)) AS "value" FROM ?? as "${this.tableAlias}", jsonb_array_elements_text(${this.tableColumnRef}::jsonb)`,
+ [this.dbTableName]
+ )
+ .toQuery();
+ }
+
+ checked(): string {
+ return this.knex
+ .raw(`SUM(CASE WHEN ${this.tableColumnRef} @> '[true]'::jsonb THEN 1 ELSE 0 END)`)
+ .toQuery();
+ }
+
+ unChecked(): string {
+ return this.knex
+ .raw(
+ `SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT (${this.tableColumnRef} @> '[true]'::jsonb) THEN 1 ELSE 0 END)`
+ )
+ .toQuery();
+ }
+
+ percentChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN ${this.tableColumnRef} @> '[true]'::jsonb THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`
+ )
+ .toQuery();
+ }
+
+ percentUnChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT (${this.tableColumnRef} @> '[true]'::jsonb) THEN 1 ELSE 0 END) * 1.0 / GREATEST(COUNT(*), 1)) * 100`
)
.toQuery();
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/single-value/single-value-aggregation.adapter.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/single-value/single-value-aggregation.adapter.ts
index 8fb7311a4b..846fa30630 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/single-value/single-value-aggregation.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/postgres/single-value/single-value-aggregation.adapter.ts
@@ -3,16 +3,13 @@ import { AggregationFunctionPostgres } from '../aggregation-function.postgres';
export class SingleValueAggregationAdapter extends AggregationFunctionPostgres {
dateRangeOfDays(): string {
return this.knex
- .raw(`extract(DAY FROM (MAX(??) - MIN(??)))::INTEGER`, [
- this.tableColumnRef,
- this.tableColumnRef,
- ])
+ .raw(`extract(DAY FROM (MAX(${this.tableColumnRef}) - MIN(${this.tableColumnRef})))::INTEGER`)
.toQuery();
}
dateRangeOfMonths(): string {
return this.knex
- .raw(`CONCAT(MAX(??), ',', MIN(??))`, [this.tableColumnRef, this.tableColumnRef])
+ .raw(`CONCAT(MAX(${this.tableColumnRef}), ',', MIN(${this.tableColumnRef}))`)
.toQuery();
}
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-function.sqlite.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-function.sqlite.ts
index 5cb3f8775b..22cc0e2b43 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-function.sqlite.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-function.sqlite.ts
@@ -5,25 +5,31 @@ import { AbstractAggregationFunction } from '../aggregation-function.abstract';
export class AggregationFunctionSqlite extends AbstractAggregationFunction {
unique(): string {
const { type, isMultipleCellValue } = this.field;
- if (type !== FieldType.User || isMultipleCellValue) {
+ if (
+ ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||
+ isMultipleCellValue
+ ) {
return super.unique();
}
- return this.knex
- .raw(`COUNT(DISTINCT json_extract(??, '$.id'))`, [this.tableColumnRef])
- .toQuery();
+ return this.knex.raw(`COUNT(DISTINCT json_extract(${this.tableColumnRef}, '$.id'))`).toQuery();
}
percentUnique(): string {
const { type, isMultipleCellValue } = this.field;
- if (type !== FieldType.User || isMultipleCellValue) {
- return super.percentUnique();
+ if (
+ ![FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(type) ||
+ isMultipleCellValue
+ ) {
+ return this.knex
+ .raw(`(COUNT(DISTINCT ${this.tableColumnRef}) * 1.0 / MAX(COUNT(*), 1)) * 100`)
+ .toQuery();
}
return this.knex
- .raw(`(COUNT(DISTINCT json_extract(??, '$.id')) * 1.0 / COUNT(*)) * 100`, [
- this.tableColumnRef,
- ])
+ .raw(
+ `(COUNT(DISTINCT json_extract(${this.tableColumnRef}, '$.id')) * 1.0 / MAX(COUNT(*), 1)) * 100`
+ )
.toQuery();
}
dateRangeOfDays(): string {
@@ -35,6 +41,52 @@ export class AggregationFunctionSqlite extends AbstractAggregationFunction {
}
totalAttachmentSize(): string {
- return `SELECT SUM(json_extract(json_each.value, '$.size')) AS value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ // Sum sizes per row, then sum across the current scope (respects GROUP BY)
+ return this.knex
+ .raw(
+ `SUM(COALESCE((SELECT SUM(json_extract(j.value, '$.size'))
+ FROM json_each(COALESCE(${this.tableColumnRef}, '[]')) AS j), 0))`
+ )
+ .toQuery();
+ }
+
+ percentEmpty(): string {
+ return this.knex
+ .raw(`((COUNT(*) - COUNT(${this.tableColumnRef})) * 1.0 / MAX(COUNT(*), 1)) * 100`)
+ .toQuery();
+ }
+
+ percentFilled(): string {
+ return this.knex
+ .raw(`(COUNT(${this.tableColumnRef}) * 1.0 / MAX(COUNT(*), 1)) * 100`)
+ .toQuery();
+ }
+
+ checked(): string {
+ return this.knex.raw(`SUM(CASE WHEN ${this.tableColumnRef} = 1 THEN 1 ELSE 0 END)`).toQuery();
+ }
+
+ unChecked(): string {
+ return this.knex
+ .raw(
+ `SUM(CASE WHEN ${this.tableColumnRef} = 0 OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END)`
+ )
+ .toQuery();
+ }
+
+ percentChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN ${this.tableColumnRef} = 1 THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`
+ )
+ .toQuery();
+ }
+
+ percentUnChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN ${this.tableColumnRef} = 0 OR ${this.tableColumnRef} IS NULL THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`
+ )
+ .toQuery();
}
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-query.sqlite.ts
index 617ef18149..3e4d036433 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-query.sqlite.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/aggregation-query.sqlite.ts
@@ -1,35 +1,35 @@
-import type { IFieldInstance } from '../../../features/field/model/factory';
+import type { FieldCore } from '@teable/core';
import { AbstractAggregationQuery } from '../aggregation-query.abstract';
import type { AggregationFunctionSqlite } from './aggregation-function.sqlite';
import { MultipleValueAggregationAdapter } from './multiple-value/multiple-value-aggregation.adapter';
import { SingleValueAggregationAdapter } from './single-value/single-value-aggregation.adapter';
export class AggregationQuerySqlite extends AbstractAggregationQuery {
- private coreAggregation(field: IFieldInstance): AggregationFunctionSqlite {
+ private coreAggregation(field: FieldCore): AggregationFunctionSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleValueAggregationAdapter(this.knex, this.dbTableName, field);
+ return new MultipleValueAggregationAdapter(this.knex, field, this.context);
}
- return new SingleValueAggregationAdapter(this.knex, this.dbTableName, field);
+ return new SingleValueAggregationAdapter(this.knex, field, this.context);
}
- booleanAggregation(field: IFieldInstance): AggregationFunctionSqlite {
+ booleanAggregation(field: FieldCore): AggregationFunctionSqlite {
return this.coreAggregation(field);
}
- numberAggregation(field: IFieldInstance): AggregationFunctionSqlite {
+ numberAggregation(field: FieldCore): AggregationFunctionSqlite {
return this.coreAggregation(field);
}
- dateTimeAggregation(field: IFieldInstance): AggregationFunctionSqlite {
+ dateTimeAggregation(field: FieldCore): AggregationFunctionSqlite {
return this.coreAggregation(field);
}
- stringAggregation(field: IFieldInstance): AggregationFunctionSqlite {
+ stringAggregation(field: FieldCore): AggregationFunctionSqlite {
return this.coreAggregation(field);
}
- jsonAggregation(field: IFieldInstance): AggregationFunctionSqlite {
+ jsonAggregation(field: FieldCore): AggregationFunctionSqlite {
return this.coreAggregation(field);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/multiple-value/multiple-value-aggregation.adapter.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/multiple-value/multiple-value-aggregation.adapter.ts
index 3b478d8f63..db6e3a00ad 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/multiple-value/multiple-value-aggregation.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/multiple-value/multiple-value-aggregation.adapter.ts
@@ -2,34 +2,106 @@ import { AggregationFunctionSqlite } from '../aggregation-function.sqlite';
export class MultipleValueAggregationAdapter extends AggregationFunctionSqlite {
unique(): string {
- return `SELECT COUNT(DISTINCT json_each.value) as value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT COUNT(DISTINCT json_each.value) as value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
}
max(): string {
- return `SELECT MAX(json_each.value) as value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT MAX(json_each.value) as value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
}
min(): string {
- return `SELECT MIN(json_each.value) as value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT MIN(json_each.value) as value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
}
sum(): string {
- return `SELECT SUM(json_each.value) as value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT SUM(json_each.value) as value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
}
average(): string {
- return `SELECT AVG(json_each.value) as value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT AVG(json_each.value) as value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
}
percentUnique(): string {
- return `SELECT (COUNT(DISTINCT json_each.value) * 1.0 / COUNT(*)) * 100 AS value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT (COUNT(DISTINCT json_each.value) * 1.0 / MAX(COUNT(*), 1)) * 100 AS value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
}
dateRangeOfDays(): string {
- return `SELECT CAST(julianday(MAX(json_each.value)) - julianday(MIN(json_each.value)) AS INTEGER) AS value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT CAST(julianday(MAX(json_each.value)) - julianday(MIN(json_each.value)) AS INTEGER) AS value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
}
dateRangeOfMonths(): string {
- return `SELECT MAX(json_each.value) || ',' || MIN(json_each.value) AS value FROM ${this.dbTableName}, json_each(${this.tableColumnRef})`;
+ return this.knex
+ .raw(
+ `SELECT MAX(json_each.value) || ',' || MIN(json_each.value) AS value FROM ?? as "${this.tableAlias}", json_each(${this.tableColumnRef})`,
+ [this.dbTableName]
+ )
+ .toQuery();
+ }
+
+ checked(): string {
+ return this.knex
+ .raw(
+ `SUM(CASE WHEN EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END)`
+ )
+ .toQuery();
+ }
+
+ unChecked(): string {
+ return this.knex
+ .raw(
+ `SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END)`
+ )
+ .toQuery();
+ }
+
+ percentChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`
+ )
+ .toQuery();
+ }
+
+ percentUnChecked(): string {
+ return this.knex
+ .raw(
+ `(SUM(CASE WHEN ${this.tableColumnRef} IS NULL OR NOT EXISTS (SELECT 1 FROM json_each(${this.tableColumnRef}) WHERE json_each.value = 1) THEN 1 ELSE 0 END) * 1.0 / MAX(COUNT(*), 1)) * 100`
+ )
+ .toQuery();
}
}
diff --git a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/single-value/single-value-aggregation.adapter.ts b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/single-value/single-value-aggregation.adapter.ts
index a01be79274..3c25f9c2a0 100644
--- a/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/single-value/single-value-aggregation.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/aggregation-query/sqlite/single-value/single-value-aggregation.adapter.ts
@@ -2,10 +2,16 @@ import { AggregationFunctionSqlite } from '../aggregation-function.sqlite';
export class SingleValueAggregationAdapter extends AggregationFunctionSqlite {
dateRangeOfDays(): string {
- return `CAST(julianday(MAX(${this.tableColumnRef})) - julianday(MIN(${this.tableColumnRef})) as INTEGER)`;
+ return this.knex
+ .raw(
+ `CAST(julianday(MAX(${this.tableColumnRef})) - julianday(MIN(${this.tableColumnRef})) as INTEGER)`
+ )
+ .toQuery();
}
dateRangeOfMonths(): string {
- return `MAX(${this.tableColumnRef}) || ',' || MIN(${this.tableColumnRef})`;
+ return this.knex
+ .raw(`MAX(${this.tableColumnRef}) || ',' || MIN(${this.tableColumnRef})`)
+ .toQuery();
}
}
diff --git a/apps/nestjs-backend/src/db-provider/base-query/abstract.ts b/apps/nestjs-backend/src/db-provider/base-query/abstract.ts
new file mode 100644
index 0000000000..e983638879
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/base-query/abstract.ts
@@ -0,0 +1,11 @@
+import type { Knex } from 'knex';
+
+export abstract class BaseQueryAbstract {
+ constructor(protected readonly knex: Knex) {}
+
+ abstract jsonSelect(
+ queryBuilder: Knex.QueryBuilder,
+ dbFieldName: string,
+ alias: string
+ ): Knex.QueryBuilder;
+}
diff --git a/apps/nestjs-backend/src/db-provider/base-query/base-query.postgres.ts b/apps/nestjs-backend/src/db-provider/base-query/base-query.postgres.ts
new file mode 100644
index 0000000000..f18d8db338
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/base-query/base-query.postgres.ts
@@ -0,0 +1,17 @@
+import type { Knex } from 'knex';
+import { BaseQueryAbstract } from './abstract';
+
+export class BaseQueryPostgres extends BaseQueryAbstract {
+ constructor(protected readonly knex: Knex) {
+ super(knex);
+ }
+
+ jsonSelect(
+ queryBuilder: Knex.QueryBuilder,
+ dbFieldName: string,
+ alias: string
+ ): Knex.QueryBuilder {
+ // dbFieldName is a pre-quoted qualified identifier path
+ return queryBuilder.select(this.knex.raw(`MAX(${dbFieldName}::text) AS ??`, [alias]));
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/base-query/base-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/base-query/base-query.sqlite.ts
new file mode 100644
index 0000000000..5a12881bec
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/base-query/base-query.sqlite.ts
@@ -0,0 +1,16 @@
+import type { Knex } from 'knex';
+import { BaseQueryAbstract } from './abstract';
+
+export class BaseQuerySqlite extends BaseQueryAbstract {
+ constructor(protected readonly knex: Knex) {
+ super(knex);
+ }
+
+ jsonSelect(
+ queryBuilder: Knex.QueryBuilder,
+ dbFieldName: string,
+ alias: string
+ ): Knex.QueryBuilder {
+ return queryBuilder.select(this.knex.raw(`MAX(??) AS ??`, [dbFieldName, alias]));
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.interface.ts b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.interface.ts
new file mode 100644
index 0000000000..58d261d8ff
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.interface.ts
@@ -0,0 +1,39 @@
+import type { TableDomain } from '@teable/core';
+import type { Knex } from 'knex';
+import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IDbProvider } from '../db.provider.interface';
+
+/**
+ * Context interface for database column creation
+ */
+export interface ICreateDatabaseColumnContext {
+ /** Knex table builder instance */
+ table: Knex.CreateTableBuilder;
+ tableDomain: TableDomain;
+ /** Field ID */
+ fieldId: string;
+ /** the Field instance to add */
+ field: IFieldInstance;
+ /** Database field name */
+ dbFieldName: string;
+ /** Whether the field is unique */
+ unique?: boolean;
+ /** Whether the field is not null */
+ notNull?: boolean;
+ /** Database provider for formula conversion */
+ dbProvider?: IDbProvider;
+ /** Whether this is a new table creation (affects SQLite generated columns) */
+ isNewTable?: boolean;
+ /** Current table ID (for link field foreign key creation) */
+ tableId: string;
+ /** Current table name (for link field foreign key creation) */
+ tableName: string;
+ /** Knex instance (for link field foreign key creation) */
+ knex: Knex;
+ /** Table name mapping for foreign key creation (tableId -> dbTableName) */
+ tableNameMap: Map;
+ /** Whether this is a symmetric field (should not create foreign key structures) */
+ isSymmetricField?: boolean;
+ /** When true, do not create the base column for Link fields (FK/junction only). */
+ skipBaseColumnCreation?: boolean;
+}
diff --git a/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts
new file mode 100644
index 0000000000..e54a134803
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.postgres.ts
@@ -0,0 +1,455 @@
+import type {
+ AttachmentFieldCore,
+ AutoNumberFieldCore,
+ CheckboxFieldCore,
+ CreatedByFieldCore,
+ CreatedTimeFieldCore,
+ DateFieldCore,
+ FormulaFieldCore,
+ LastModifiedByFieldCore,
+ LastModifiedTimeFieldCore,
+ LinkFieldCore,
+ LongTextFieldCore,
+ MultipleSelectFieldCore,
+ NumberFieldCore,
+ RatingFieldCore,
+ RollupFieldCore,
+ ConditionalRollupFieldCore,
+ SingleLineTextFieldCore,
+ SingleSelectFieldCore,
+ UserFieldCore,
+ IFieldVisitor,
+ FieldCore,
+ ILinkFieldOptions,
+ ButtonFieldCore,
+} from '@teable/core';
+import { DbFieldType, Relationship } from '@teable/core';
+import type { Knex } from 'knex';
+import type { AutoNumberFieldDto } from '../../features/field/model/field-dto/auto-number-field.dto';
+import type { CreatedByFieldDto } from '../../features/field/model/field-dto/created-by-field.dto';
+import type { CreatedTimeFieldDto } from '../../features/field/model/field-dto/created-time-field.dto';
+import type { FormulaFieldDto } from '../../features/field/model/field-dto/formula-field.dto';
+import type { LastModifiedByFieldDto } from '../../features/field/model/field-dto/last-modified-by-field.dto';
+import type { LastModifiedTimeFieldDto } from '../../features/field/model/field-dto/last-modified-time-field.dto';
+import type { LinkFieldDto } from '../../features/field/model/field-dto/link-field.dto';
+import { SchemaType } from '../../features/field/util';
+import type { IFormulaConversionContext } from '../../features/record/query-builder/sql-conversion.visitor';
+import { GeneratedColumnQuerySupportValidatorPostgres } from '../generated-column-query/postgres/generated-column-query-support-validator.postgres';
+import type { ICreateDatabaseColumnContext } from './create-database-column-field-visitor.interface';
+import { validateGeneratedColumnSupport } from './create-database-column-field.util';
+
+/**
+ * PostgreSQL implementation of database column visitor.
+ */
+export class CreatePostgresDatabaseColumnFieldVisitor implements IFieldVisitor {
+ private sql: string[] = [];
+
+ constructor(private readonly context: ICreateDatabaseColumnContext) {}
+
+ getSql(): string[] {
+ return this.sql;
+ }
+
+ private getSchemaType(dbFieldType: DbFieldType): SchemaType {
+ switch (dbFieldType) {
+ case DbFieldType.Blob:
+ return SchemaType.Binary;
+ case DbFieldType.Integer:
+ return SchemaType.Integer;
+ case DbFieldType.Json:
+ // PostgreSQL supports native JSONB
+ return SchemaType.Jsonb;
+ case DbFieldType.Real:
+ return SchemaType.Double;
+ case DbFieldType.Text:
+ return SchemaType.Text;
+ case DbFieldType.DateTime:
+ return SchemaType.Datetime;
+ case DbFieldType.Boolean:
+ return SchemaType.Boolean;
+ default:
+ throw new Error(`Unsupported DbFieldType: ${dbFieldType}`);
+ }
+ }
+
+ private createStandardColumn(field: FieldCore): void {
+ const schemaType = this.getSchemaType(field.dbFieldType);
+ const column = this.context.table[schemaType](this.context.dbFieldName);
+
+ if (this.context.notNull) {
+ column.notNullable();
+ }
+
+ if (this.context.unique) {
+ column.unique();
+ }
+ }
+
+ private createFormulaColumns(field: FormulaFieldCore): void {
+ const formulaFieldDto = this.context.field as FormulaFieldDto;
+ const clearPersistedGeneratedMeta = () => {
+ formulaFieldDto.meta = undefined;
+ };
+ // Never persist lookup formulas as generated columns; they may be multi-valued (JSON)
+ // and/or depend on link/lookup resolution logic not suitable for generated columns.
+ if (field.isLookup || field.isMultipleCellValue) {
+ clearPersistedGeneratedMeta();
+ this.createStandardColumn(field);
+ return;
+ }
+ if (this.context.dbProvider) {
+ const generatedColumnName = field.getGeneratedColumnName();
+ const columnType = this.getPostgresColumnType(field.dbFieldType);
+
+ const expression = field.getExpression();
+
+ // Skip if no expression
+ if (!expression) {
+ // Fallback to a standard column if no expression
+ clearPersistedGeneratedMeta();
+ this.createStandardColumn(field);
+ return;
+ }
+
+ // Check if the formula is supported for generated columns
+ const supportValidator = new GeneratedColumnQuerySupportValidatorPostgres();
+ const isSupported = validateGeneratedColumnSupport(
+ field,
+ supportValidator,
+ this.context.tableDomain
+ );
+
+ if (isSupported) {
+ const conversionContext: IFormulaConversionContext = {
+ table: this.context.tableDomain,
+ isGeneratedColumn: true, // Mark this as a generated column context
+ };
+
+ const conversionResult = this.context.dbProvider.convertFormulaToGeneratedColumn(
+ expression,
+ conversionContext
+ );
+
+ // Create generated column using specificType
+ // PostgreSQL syntax: GENERATED ALWAYS AS (expression) STORED
+ const generatedColumnDefinition = `${columnType} GENERATED ALWAYS AS (${conversionResult.sql}) STORED`;
+
+ this.context.table.specificType(generatedColumnName, generatedColumnDefinition);
+ (this.context.field as FormulaFieldDto).setMetadata({ persistedAsGeneratedColumn: true });
+ return;
+ }
+ }
+ // Fallback: create a standard column when not supported as generated
+ clearPersistedGeneratedMeta();
+ this.createStandardColumn(field);
+ }
+
+ private getPostgresColumnType(dbFieldType: DbFieldType): string {
+ switch (dbFieldType) {
+ case DbFieldType.Text:
+ return 'TEXT';
+ case DbFieldType.Integer:
+ return 'INTEGER';
+ case DbFieldType.Real:
+ return 'DOUBLE PRECISION';
+ case DbFieldType.Boolean:
+ return 'BOOLEAN';
+ case DbFieldType.DateTime:
+ return 'TIMESTAMP';
+ case DbFieldType.Json:
+ return 'JSONB';
+ case DbFieldType.Blob:
+ return 'BYTEA';
+ default:
+ return 'TEXT';
+ }
+ }
+
+ // Basic field types
+ visitNumberField(field: NumberFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitSingleLineTextField(field: SingleLineTextFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitLongTextField(field: LongTextFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitAttachmentField(field: AttachmentFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitCheckboxField(field: CheckboxFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitDateField(field: DateFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitRatingField(field: RatingFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitAutoNumberField(_field: AutoNumberFieldCore): void {
+ this.context.table.specificType(
+ this.context.dbFieldName,
+ 'INTEGER GENERATED ALWAYS AS (__auto_number) STORED'
+ );
+ (this.context.field as AutoNumberFieldDto).setMetadata({
+ persistedAsGeneratedColumn: true,
+ });
+ }
+
+ visitLinkField(field: LinkFieldCore): void {
+ // Determine potential conflicts with FK column names (including inferred defaults)
+ const opts = field.options as ILinkFieldOptions;
+ const conflictNames = new Set();
+ const rel = opts?.relationship;
+ const inferredFkName =
+ opts?.foreignKeyName ??
+ (rel === Relationship.ManyOne || rel === Relationship.OneOne
+ ? this.context.dbFieldName
+ : undefined);
+ const inferredSelfName =
+ opts?.selfKeyName ??
+ (rel === Relationship.OneMany && opts?.isOneWay === false
+ ? this.context.dbFieldName
+ : undefined);
+ if (inferredFkName) conflictNames.add(inferredFkName);
+ if (inferredSelfName) conflictNames.add(inferredSelfName);
+
+ // Create underlying base column only if no conflict with FK/self columns
+ if (!this.context.skipBaseColumnCreation && !conflictNames.has(this.context.dbFieldName)) {
+ this.createStandardColumn(field);
+ }
+
+ // For real link structures, create FK/junction artifacts on non-symmetric side
+ if (field.isLookup) return;
+ if (this.context.isSymmetricField || this.isSymmetricField(field)) return;
+ this.createForeignKeyForLinkField(field);
+ }
+
+ private isSymmetricField(_field: LinkFieldCore): boolean {
+ // A field is symmetric if it has a symmetricFieldId that points to an existing field
+ // In practice, when creating symmetric fields, they are created after the main field
+ // So we can check if this field's symmetricFieldId exists in the database
+ // For now, we'll rely on the isSymmetricField context flag
+ return false;
+ }
+
+ private createForeignKeyForLinkField(field: LinkFieldCore): void {
+ const options = field.options as ILinkFieldOptions;
+ const { relationship, fkHostTableName, selfKeyName, foreignKeyName, isOneWay, foreignTableId } =
+ options;
+
+ if (
+ !this.context.knex ||
+ !this.context.tableId ||
+ !this.context.tableName ||
+ !this.context.tableNameMap
+ ) {
+ return;
+ }
+
+ // Get table names from context
+ const dbTableName = this.context.tableName;
+ const foreignDbTableName = this.context.tableNameMap.get(foreignTableId);
+
+ if (!foreignDbTableName) {
+ throw new Error(`Foreign table not found: ${foreignTableId}`);
+ }
+
+ let alterTableSchema: Knex.SchemaBuilder | undefined;
+
+ if (relationship === Relationship.ManyMany) {
+ alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {
+ table.increments('__id').primary();
+ table
+ .string(selfKeyName)
+ .references('__id')
+ .inTable(dbTableName)
+ .withKeyName(`fk_${selfKeyName}`);
+ table
+ .string(foreignKeyName)
+ .references('__id')
+ .inTable(foreignDbTableName)
+ .withKeyName(`fk_${foreignKeyName}`);
+ // Add order column for maintaining insertion order
+ table.integer('__order').nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+
+ if (relationship === Relationship.ManyOne) {
+ alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {
+ table
+ .string(foreignKeyName)
+ .references('__id')
+ .inTable(foreignDbTableName)
+ .withKeyName(`fk_${foreignKeyName}`);
+ // Add order column for maintaining insertion order
+ table.integer(`${foreignKeyName}_order`).nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+
+ if (relationship === Relationship.OneMany) {
+ if (isOneWay) {
+ alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {
+ table.increments('__id').primary();
+ table
+ .string(selfKeyName)
+ .references('__id')
+ .inTable(dbTableName)
+ .withKeyName(`fk_${selfKeyName}`);
+ table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);
+ table.unique([selfKeyName, foreignKeyName], {
+ indexName: `index_${selfKeyName}_${foreignKeyName}`,
+ });
+ });
+ } else {
+ alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {
+ table
+ .string(selfKeyName)
+ .references('__id')
+ .inTable(dbTableName)
+ .withKeyName(`fk_${selfKeyName}`);
+ // Add order column for maintaining insertion order
+ table.integer(`${selfKeyName}_order`).nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+ }
+
+ // assume options is from the main field (user created one)
+ if (relationship === Relationship.OneOne) {
+ alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {
+ if (foreignKeyName === '__id') {
+ throw new Error('can not use __id for foreignKeyName');
+ }
+ table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);
+ table.unique([foreignKeyName], {
+ indexName: `index_${foreignKeyName}`,
+ });
+ // Add order column for maintaining insertion order
+ table.integer(`${foreignKeyName}_order`).nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+
+ if (!alterTableSchema) {
+ throw new Error('alterTableSchema is undefined');
+ }
+
+ // Store the SQL queries to be executed later
+ for (const sql of alterTableSchema.toSQL()) {
+ // skip sqlite pragma
+ if (sql.sql.startsWith('PRAGMA')) {
+ continue;
+ }
+ this.sql.push(sql.sql);
+ }
+ }
+
+ visitRollupField(field: RollupFieldCore): void {
+ // Always create an underlying base column for rollup fields
+ this.createStandardColumn(field);
+ }
+
+ visitConditionalRollupField(field: ConditionalRollupFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ // Select field types
+ visitSingleSelectField(field: SingleSelectFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitMultipleSelectField(field: MultipleSelectFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitButtonField(field: ButtonFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ // Formula field types
+ visitFormulaField(field: FormulaFieldCore): void {
+ this.createFormulaColumns(field);
+ }
+
+ visitCreatedTimeField(field: CreatedTimeFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ this.context.table.specificType(
+ this.context.dbFieldName,
+ 'TIMESTAMP GENERATED ALWAYS AS (__created_time) STORED'
+ );
+ (this.context.field as CreatedTimeFieldDto).setMetadata({
+ persistedAsGeneratedColumn: true,
+ });
+ }
+
+ visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ const trackAll = field.isTrackAll();
+
+ if (trackAll) {
+ this.context.table.specificType(
+ this.context.dbFieldName,
+ 'TIMESTAMP GENERATED ALWAYS AS (__last_modified_time) STORED'
+ );
+ (this.context.field as LastModifiedTimeFieldDto).setMetadata({
+ persistedAsGeneratedColumn: true,
+ });
+ return;
+ }
+
+ this.context.table.timestamp(this.context.dbFieldName, { useTz: true });
+ (this.context.field as LastModifiedTimeFieldDto).setMetadata({
+ persistedAsGeneratedColumn: false,
+ });
+ }
+
+ // User field types
+ visitUserField(field: UserFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitCreatedByField(field: CreatedByFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ // Persist as a JSON column (stores collaborator payload)
+ this.createStandardColumn(field);
+ (this.context.field as CreatedByFieldDto).setMetadata({
+ persistedAsGeneratedColumn: false,
+ });
+ }
+
+ visitLastModifiedByField(field: LastModifiedByFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ // Persist as a JSON column (stores collaborator payload)
+ this.createStandardColumn(field);
+ (this.context.field as LastModifiedByFieldDto).setMetadata({
+ persistedAsGeneratedColumn: false,
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.sqlite.ts b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.sqlite.ts
new file mode 100644
index 0000000000..9effaa58fd
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field-visitor.sqlite.ts
@@ -0,0 +1,453 @@
+import type {
+ AttachmentFieldCore,
+ AutoNumberFieldCore,
+ CheckboxFieldCore,
+ CreatedByFieldCore,
+ CreatedTimeFieldCore,
+ DateFieldCore,
+ FormulaFieldCore,
+ LastModifiedByFieldCore,
+ LastModifiedTimeFieldCore,
+ LinkFieldCore,
+ LongTextFieldCore,
+ MultipleSelectFieldCore,
+ NumberFieldCore,
+ RatingFieldCore,
+ RollupFieldCore,
+ ConditionalRollupFieldCore,
+ SingleLineTextFieldCore,
+ SingleSelectFieldCore,
+ UserFieldCore,
+ IFieldVisitor,
+ FieldCore,
+ ILinkFieldOptions,
+ ButtonFieldCore,
+} from '@teable/core';
+import { DbFieldType, Relationship } from '@teable/core';
+import type { Knex } from 'knex';
+import type { AutoNumberFieldDto } from '../../features/field/model/field-dto/auto-number-field.dto';
+import type { CreatedByFieldDto } from '../../features/field/model/field-dto/created-by-field.dto';
+import type { CreatedTimeFieldDto } from '../../features/field/model/field-dto/created-time-field.dto';
+import type { FormulaFieldDto } from '../../features/field/model/field-dto/formula-field.dto';
+import type { LastModifiedByFieldDto } from '../../features/field/model/field-dto/last-modified-by-field.dto';
+import type { LastModifiedTimeFieldDto } from '../../features/field/model/field-dto/last-modified-time-field.dto';
+import type { LinkFieldDto } from '../../features/field/model/field-dto/link-field.dto';
+import { SchemaType } from '../../features/field/util';
+import type { IFormulaConversionContext } from '../../features/record/query-builder/sql-conversion.visitor';
+import { GeneratedColumnQuerySupportValidatorSqlite } from '../generated-column-query/sqlite/generated-column-query-support-validator.sqlite';
+import type { ICreateDatabaseColumnContext } from './create-database-column-field-visitor.interface';
+import { validateGeneratedColumnSupport } from './create-database-column-field.util';
+
+/**
+ * SQLite implementation of database column visitor.
+ */
+export class CreateSqliteDatabaseColumnFieldVisitor implements IFieldVisitor {
+ private sql: string[] = [];
+
+ constructor(private readonly context: ICreateDatabaseColumnContext) {}
+
+ getSql(): string[] {
+ return this.sql;
+ }
+
+ private getSchemaType(dbFieldType: DbFieldType): SchemaType {
+ switch (dbFieldType) {
+ case DbFieldType.Blob:
+ return SchemaType.Binary;
+ case DbFieldType.Integer:
+ return SchemaType.Integer;
+ case DbFieldType.Json:
+ // SQLite stores JSON as TEXT
+ return SchemaType.Text;
+ case DbFieldType.Real:
+ return SchemaType.Double;
+ case DbFieldType.Text:
+ return SchemaType.Text;
+ case DbFieldType.DateTime:
+ return SchemaType.Datetime;
+ case DbFieldType.Boolean:
+ return SchemaType.Boolean;
+ default:
+ throw new Error(`Unsupported DbFieldType: ${dbFieldType}`);
+ }
+ }
+
+ private createStandardColumn(field: FieldCore): void {
+ const schemaType = this.getSchemaType(field.dbFieldType);
+ const column = this.context.table[schemaType](this.context.dbFieldName);
+
+ if (this.context.notNull) {
+ column.notNullable();
+ }
+
+ if (this.context.unique) {
+ column.unique();
+ }
+ }
+
+ private createFormulaColumns(field: FormulaFieldCore): void {
+ const formulaFieldDto = this.context.field as FormulaFieldDto;
+ const clearPersistedGeneratedMeta = () => {
+ formulaFieldDto.meta = undefined;
+ };
+ if (this.context.dbProvider) {
+ const generatedColumnName = field.getGeneratedColumnName();
+ const columnType = this.getSqliteColumnType(field.dbFieldType);
+
+ // Use original expression since expansion logic has been moved
+ const expressionToConvert = field.options.expression;
+ // Skip if no expression
+ if (!expressionToConvert) {
+ // Fallback to a standard column if no expression
+ clearPersistedGeneratedMeta();
+ this.createStandardColumn(field);
+ return;
+ }
+
+ // Check if the formula is supported for generated columns
+ const supportValidator = new GeneratedColumnQuerySupportValidatorSqlite();
+ const isSupported = validateGeneratedColumnSupport(
+ field,
+ supportValidator,
+ this.context.tableDomain
+ );
+
+ if (isSupported) {
+ const conversionContext: IFormulaConversionContext = {
+ table: this.context.tableDomain,
+ isGeneratedColumn: true, // Mark this as a generated column context
+ };
+
+ const conversionResult = this.context.dbProvider.convertFormulaToGeneratedColumn(
+ expressionToConvert,
+ conversionContext
+ );
+
+ // Create generated column using specificType
+ // SQLite syntax: GENERATED ALWAYS AS (expression) VIRTUAL/STORED
+ // Note: For ALTER TABLE operations, SQLite doesn't support STORED generated columns
+ const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';
+ const notNullClause = this.context.notNull ? ' NOT NULL' : '';
+ const generatedColumnDefinition = `${columnType} GENERATED ALWAYS AS (${conversionResult.sql}) ${storageType}${notNullClause}`;
+
+ this.context.table.specificType(generatedColumnName, generatedColumnDefinition);
+ (this.context.field as FormulaFieldDto).setMetadata({ persistedAsGeneratedColumn: true });
+ return;
+ }
+ }
+ // Fallback: create a standard column when not supported as generated
+ clearPersistedGeneratedMeta();
+ this.createStandardColumn(field);
+ }
+
+ private getSqliteColumnType(dbFieldType: DbFieldType): string {
+ switch (dbFieldType) {
+ case DbFieldType.Text:
+ return 'TEXT';
+ case DbFieldType.Integer:
+ return 'INTEGER';
+ case DbFieldType.Real:
+ return 'REAL';
+ case DbFieldType.Boolean:
+ return 'INTEGER'; // SQLite uses INTEGER for boolean
+ case DbFieldType.DateTime:
+ return 'TEXT'; // SQLite stores datetime as TEXT
+ case DbFieldType.Json:
+ return 'TEXT'; // SQLite stores JSON as TEXT
+ case DbFieldType.Blob:
+ return 'BLOB';
+ default:
+ return 'TEXT';
+ }
+ }
+
+ // Basic field types
+ visitNumberField(field: NumberFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitSingleLineTextField(field: SingleLineTextFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitLongTextField(field: LongTextFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitAttachmentField(field: AttachmentFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitCheckboxField(field: CheckboxFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitDateField(field: DateFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitRatingField(field: RatingFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitAutoNumberField(_field: AutoNumberFieldCore): void {
+ // SQLite syntax: GENERATED ALWAYS AS (expression) STORED/VIRTUAL
+ // For ALTER TABLE operations, SQLite doesn't support STORED generated columns, so use VIRTUAL
+ const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';
+ this.context.table.specificType(
+ this.context.dbFieldName,
+ `INTEGER GENERATED ALWAYS AS (__auto_number) ${storageType}`
+ );
+ (this.context.field as AutoNumberFieldDto).setMetadata({
+ persistedAsGeneratedColumn: true,
+ });
+ }
+
+ visitLinkField(field: LinkFieldCore): void {
+ // Ensure underlying column representation for link fields unless conflicts with FK column names
+ const opts = field.options as ILinkFieldOptions;
+ const conflictNames = new Set();
+ const rel = opts?.relationship;
+ const inferredFkName =
+ opts?.foreignKeyName ??
+ (rel === Relationship.ManyOne || rel === Relationship.OneOne
+ ? this.context.dbFieldName
+ : undefined);
+ const inferredSelfName =
+ opts?.selfKeyName ??
+ (rel === Relationship.OneMany && opts?.isOneWay === false
+ ? this.context.dbFieldName
+ : undefined);
+ if (inferredFkName) conflictNames.add(inferredFkName);
+ if (inferredSelfName) conflictNames.add(inferredSelfName);
+
+ if (!this.context.skipBaseColumnCreation && !conflictNames.has(this.context.dbFieldName)) {
+ this.createStandardColumn(field);
+ }
+
+ if (field.isLookup) return;
+ if (this.context.isSymmetricField || this.isSymmetricField(field)) return;
+ this.createForeignKeyForLinkField(field);
+ }
+
+ private isSymmetricField(_field: LinkFieldCore): boolean {
+ // A field is symmetric if it has a symmetricFieldId that points to an existing field
+ // In practice, when creating symmetric fields, they are created after the main field
+ // So we can check if this field's symmetricFieldId exists in the database
+ // For now, we'll rely on the isSymmetricField context flag
+ return false;
+ }
+
+ private createForeignKeyForLinkField(field: LinkFieldCore): void {
+ const options = field.options as ILinkFieldOptions;
+ const { relationship, fkHostTableName, selfKeyName, foreignKeyName, isOneWay, foreignTableId } =
+ options;
+
+ if (
+ !this.context.knex ||
+ !this.context.tableId ||
+ !this.context.tableName ||
+ !this.context.tableNameMap
+ ) {
+ return;
+ }
+
+ // Get table names from context
+ const dbTableName = this.context.tableName;
+ const foreignDbTableName = this.context.tableNameMap.get(foreignTableId);
+
+ if (!foreignDbTableName) {
+ throw new Error(`Foreign table not found: ${foreignTableId}`);
+ }
+
+ let alterTableSchema: Knex.SchemaBuilder | undefined;
+
+ if (relationship === Relationship.ManyMany) {
+ alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {
+ table.increments('__id').primary();
+ table
+ .string(selfKeyName)
+ .references('__id')
+ .inTable(dbTableName)
+ .withKeyName(`fk_${selfKeyName}`);
+ table
+ .string(foreignKeyName)
+ .references('__id')
+ .inTable(foreignDbTableName)
+ .withKeyName(`fk_${foreignKeyName}`);
+ // Add order column for maintaining insertion order
+ table.integer('__order').nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+
+ if (relationship === Relationship.ManyOne) {
+ alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {
+ table
+ .string(foreignKeyName)
+ .references('__id')
+ .inTable(foreignDbTableName)
+ .withKeyName(`fk_${foreignKeyName}`);
+ // Add order column for maintaining insertion order
+ table.integer(`${foreignKeyName}_order`).nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+
+ if (relationship === Relationship.OneMany) {
+ if (isOneWay) {
+ alterTableSchema = this.context.knex.schema.createTable(fkHostTableName, (table) => {
+ table.increments('__id').primary();
+ table
+ .string(selfKeyName)
+ .references('__id')
+ .inTable(dbTableName)
+ .withKeyName(`fk_${selfKeyName}`);
+ table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);
+ table.unique([selfKeyName, foreignKeyName], {
+ indexName: `index_${selfKeyName}_${foreignKeyName}`,
+ });
+ });
+ } else {
+ alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {
+ table
+ .string(selfKeyName)
+ .references('__id')
+ .inTable(dbTableName)
+ .withKeyName(`fk_${selfKeyName}`);
+ // Add order column for maintaining insertion order
+ table.integer(`${selfKeyName}_order`).nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+ }
+
+ // assume options is from the main field (user created one)
+ if (relationship === Relationship.OneOne) {
+ alterTableSchema = this.context.knex.schema.alterTable(fkHostTableName, (table) => {
+ if (foreignKeyName === '__id') {
+ throw new Error('can not use __id for foreignKeyName');
+ }
+ table.string(foreignKeyName).references('__id').inTable(foreignDbTableName);
+ table.unique([foreignKeyName], {
+ indexName: `index_${foreignKeyName}`,
+ });
+ // Add order column for maintaining insertion order
+ table.integer(`${foreignKeyName}_order`).nullable();
+ });
+ // Set metadata to indicate this field has order column
+ (this.context.field as LinkFieldDto).setMetadata({ hasOrderColumn: true });
+ }
+
+ if (!alterTableSchema) {
+ throw new Error('alterTableSchema is undefined');
+ }
+
+ // Store the SQL queries to be executed later
+ for (const sqlObj of alterTableSchema.toSQL()) {
+ // skip sqlite pragma
+ if (sqlObj.sql.startsWith('PRAGMA')) {
+ continue;
+ }
+ this.sql.push(sqlObj.sql);
+ }
+ }
+
+ visitRollupField(field: RollupFieldCore): void {
+ // Always create an underlying base column for rollup fields
+ this.createStandardColumn(field);
+ }
+
+ visitConditionalRollupField(field: ConditionalRollupFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ // Select field types
+ visitSingleSelectField(field: SingleSelectFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitMultipleSelectField(field: MultipleSelectFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ // Formula field types
+ visitFormulaField(field: FormulaFieldCore): void {
+ this.createFormulaColumns(field);
+ }
+
+ visitButtonField(field: ButtonFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitCreatedTimeField(field: CreatedTimeFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';
+ this.context.table.specificType(
+ this.context.dbFieldName,
+ `TEXT GENERATED ALWAYS AS (__created_time) ${storageType}`
+ );
+ (this.context.field as CreatedTimeFieldDto).setMetadata({
+ persistedAsGeneratedColumn: true,
+ });
+ }
+
+ visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ const trackAll = field.isTrackAll();
+ if (trackAll) {
+ const storageType = this.context.isNewTable ? 'STORED' : 'VIRTUAL';
+ this.context.table.specificType(
+ this.context.dbFieldName,
+ `TEXT GENERATED ALWAYS AS (__last_modified_time) ${storageType}`
+ );
+ (this.context.field as LastModifiedTimeFieldDto).setMetadata({
+ persistedAsGeneratedColumn: true,
+ });
+ return;
+ }
+
+ this.createStandardColumn(field);
+ (this.context.field as LastModifiedTimeFieldDto).setMetadata({
+ persistedAsGeneratedColumn: false,
+ });
+ }
+
+ // User field types
+ visitUserField(field: UserFieldCore): void {
+ this.createStandardColumn(field);
+ }
+
+ visitCreatedByField(field: CreatedByFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ // Persist as a JSON column (stores collaborator payload)
+ this.createStandardColumn(field);
+ (this.context.field as CreatedByFieldDto).setMetadata({
+ persistedAsGeneratedColumn: false,
+ });
+ }
+
+ visitLastModifiedByField(field: LastModifiedByFieldCore): void {
+ if (field.isLookup) {
+ this.createStandardColumn(field);
+ return;
+ }
+ // Persist as a JSON column (stores collaborator payload)
+ this.createStandardColumn(field);
+ (this.context.field as LastModifiedByFieldDto).setMetadata({
+ persistedAsGeneratedColumn: false,
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field.util.ts b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field.util.ts
new file mode 100644
index 0000000000..f04e713945
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/create-database-column-query/create-database-column-field.util.ts
@@ -0,0 +1,14 @@
+import type { FormulaFieldCore, TableDomain } from '@teable/core';
+import type { IGeneratedColumnQuerySupportValidator } from '../../features/record/query-builder/sql-conversion.visitor';
+
+export function validateGeneratedColumnSupport(
+ _field: FormulaFieldCore,
+ _supportValidator: IGeneratedColumnQuerySupportValidator,
+ _tableDomain: TableDomain
+): boolean {
+ // Temporarily disable persisting formulas as generated columns to avoid
+ // PostgreSQL restrictions (e.g., subqueries) that surface during field
+ // creation/duplication. All formulas should be computed via the runtime
+ // pipeline instead of database generated columns.
+ return false;
+}
diff --git a/apps/nestjs-backend/src/db-provider/create-database-column-query/index.ts b/apps/nestjs-backend/src/db-provider/create-database-column-query/index.ts
new file mode 100644
index 0000000000..76e89ff14c
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/create-database-column-query/index.ts
@@ -0,0 +1,3 @@
+export * from './create-database-column-field-visitor.interface';
+export * from './create-database-column-field-visitor.postgres';
+export * from './create-database-column-field-visitor.sqlite';
diff --git a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts
index e1db427bf6..ea3107dfdc 100644
--- a/apps/nestjs-backend/src/db-provider/db.provider.interface.ts
+++ b/apps/nestjs-backend/src/db-provider/db.provider.interface.ts
@@ -1,9 +1,40 @@
-import type { DriverClient, IAggregationField, IFilter, ISortItem } from '@teable/core';
+import type {
+ DriverClient,
+ FieldCore,
+ FieldType,
+ IFilter,
+ ILookupLinkOptionsVo,
+ ISortItem,
+ TableDomain,
+} from '@teable/core';
+import type { Prisma } from '@teable/db-main-prisma';
+import type { IAggregationField, ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';
import type { Knex } from 'knex';
import type { IFieldInstance } from '../features/field/model/factory';
-import type { SchemaType } from '../features/field/util';
+import type { DateFieldDto } from '../features/field/model/field-dto/date-field.dto';
+import type { IFieldSelectName } from '../features/record/query-builder/field-select.type';
+import type {
+ IRecordQueryFilterContext,
+ IRecordQuerySortContext,
+ IRecordQueryGroupContext,
+ IRecordQueryAggregateContext,
+} from '../features/record/query-builder/record-query-builder.interface';
+import type {
+ IFormulaConversionContext,
+ IFormulaConversionResult,
+ IGeneratedColumnQueryInterface,
+ ISelectFormulaConversionContext,
+ ISelectQueryInterface,
+} from '../features/record/query-builder/sql-conversion.visitor';
import type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';
+import type { BaseQueryAbstract } from './base-query/abstract';
+import type { DropColumnOperationType } from './drop-database-column-query/drop-database-column-field-visitor.interface';
+import type { DuplicateTableQueryAbstract } from './duplicate-table/abstract';
+import type { DuplicateAttachmentTableQueryAbstract } from './duplicate-table/duplicate-attachment-table-query.abstract';
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
+import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
+import type { IndexBuilderAbstract } from './index-query/index-abstract-builder';
+import type { IntegrityQueryAbstract } from './integrity-query/abstract';
import type { ISortQueryInterface } from './sort-query/sort-query.interface';
export type IFilterQueryExtra = {
@@ -16,29 +47,87 @@ export type ISortQueryExtra = {
[key: string]: unknown;
};
-export type IAggregationQueryExtra = { filter?: IFilter } & IFilterQueryExtra;
+export type IAggregationQueryExtra = { filter?: IFilter; groupBy?: string[] } & IFilterQueryExtra;
+
+export type ICalendarDailyCollectionQueryProps = {
+ startDate: string;
+ endDate: string;
+ startField: DateFieldDto;
+ endField: DateFieldDto;
+ dbTableName: string;
+};
export interface IDbProvider {
driver: DriverClient;
createSchema(schemaName: string): string[] | undefined;
+ dropSchema(schemaName: string): string | undefined;
+
generateDbTableName(baseId: string, name: string): string;
renameTableName(oldTableName: string, newTableName: string): string[];
+ getForeignKeysInfo(dbTableName: string): string;
+
dropTable(tableName: string): string;
- renameColumnName(tableName: string, oldName: string, newName: string): string[];
+ renameColumn(tableName: string, oldName: string, newName: string): string[];
- dropColumn(tableName: string, columnName: string): string[];
+ dropColumn(
+ tableName: string,
+ fieldInstance: IFieldInstance,
+ linkContext?: { tableId: string; tableNameMap: Map },
+ operationType?: DropColumnOperationType
+ ): string[];
+
+ updateJsonColumn(
+ tableName: string,
+ columnName: string,
+ id: string,
+ key: string,
+ value: string
+ ): string;
+
+ updateJsonArrayColumn(
+ tableName: string,
+ columnName: string,
+ id: string,
+ key: string,
+ value: string
+ ): string;
// sql response format: { name: string }[], name for columnName.
columnInfo(tableName: string): string;
+ checkColumnExist(
+ tableName: string,
+ columnName: string,
+ prisma: Prisma.TransactionClient
+ ): Promise;
+
+ checkTableExist(tableName: string): string;
+
dropColumnAndIndex(tableName: string, columnName: string, indexName: string): string[];
- modifyColumnSchema(tableName: string, columnName: string, schemaType: SchemaType): string[];
+ modifyColumnSchema(
+ tableName: string,
+ oldFieldInstance: IFieldInstance,
+ fieldInstance: IFieldInstance,
+ tableDomain: TableDomain,
+ linkContext?: { tableId: string; tableNameMap: Map }
+ ): string[];
+
+ createColumnSchema(
+ tableName: string,
+ fieldInstance: IFieldInstance,
+ tableDomain: TableDomain,
+ isNewTable: boolean,
+ tableId: string,
+ tableNameMap: Map,
+ isSymmetricField?: boolean,
+ skipBaseColumnCreation?: boolean
+ ): string[];
duplicateTable(
fromSchema: string,
@@ -63,25 +152,136 @@ export interface IDbProvider {
data: { id: string; values: { [key: string]: unknown } }[];
}): { insertTempTableSql: string; updateRecordSql: string };
+ updateFromSelectSql(params: {
+ dbTableName: string;
+ idFieldName: string;
+ subQuery: Knex.QueryBuilder;
+ dbFieldNames: string[];
+ returningDbFieldNames?: string[];
+ restrictRecordIds?: string[];
+ }): string;
+
+ lockRecordsSql?(params: {
+ dbTableName: string;
+ idFieldName: string;
+ recordIds: string[];
+ }): string | undefined;
+
aggregationQuery(
originQueryBuilder: Knex.QueryBuilder,
- dbTableName: string,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
aggregationFields?: IAggregationField[],
- extra?: IAggregationQueryExtra
+ extra?: IAggregationQueryExtra,
+ context?: IRecordQueryAggregateContext
): IAggregationQueryInterface;
filterQuery(
originKnex: Knex.QueryBuilder,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
filter?: IFilter,
- extra?: IFilterQueryExtra
+ extra?: IFilterQueryExtra,
+ context?: IRecordQueryFilterContext
): IFilterQueryInterface;
sortQuery(
originKnex: Knex.QueryBuilder,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
sortObjs?: ISortItem[],
- extra?: ISortQueryExtra
+ extra?: ISortQueryExtra,
+ context?: IRecordQuerySortContext
): ISortQueryInterface;
+
+ groupQuery(
+ originKnex: Knex.QueryBuilder,
+ fieldMap?: { [fieldId: string]: FieldCore },
+ groupFieldIds?: string[],
+ extra?: IGroupQueryExtra,
+ context?: IRecordQueryGroupContext
+ ): IGroupQueryInterface;
+
+ searchQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ searchFields: IFieldInstance[],
+ tableIndex: TableIndex[],
+ search: [string, string?, boolean?],
+ context?: IRecordQueryFilterContext
+ ): Knex.QueryBuilder;
+
+ searchIndexQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ dbTableName: string,
+ searchField: IFieldInstance[],
+ searchIndexRo: Partial,
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext,
+ baseSortIndex?: string,
+ setFilterQuery?: (qb: Knex.QueryBuilder) => void,
+ setSortQuery?: (qb: Knex.QueryBuilder) => void
+ ): Knex.QueryBuilder;
+
+ searchCountQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ searchField: IFieldInstance[],
+ search: [string, string?, boolean?],
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext
+ ): Knex.QueryBuilder;
+
+ searchIndex(): IndexBuilderAbstract;
+
+ duplicateTableQuery(queryBuilder: Knex.QueryBuilder): DuplicateTableQueryAbstract;
+
+ duplicateAttachmentTableQuery(
+ queryBuilder: Knex.QueryBuilder
+ ): DuplicateAttachmentTableQueryAbstract;
+
+ shareFilterCollaboratorsQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ dbFieldName: string,
+ isMultipleCellValue?: boolean | null
+ ): void;
+
+ baseQuery(): BaseQueryAbstract;
+
+ integrityQuery(): IntegrityQueryAbstract;
+
+ calendarDailyCollectionQuery(
+ qb: Knex.QueryBuilder,
+ props: ICalendarDailyCollectionQueryProps
+ ): Knex.QueryBuilder;
+
+ lookupOptionsQuery(optionsKey: keyof ILookupLinkOptionsVo, value: string): string;
+
+ optionsQuery(type: FieldType, optionsKey: string, value: string): string;
+
+ searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder;
+
+ getTableIndexes(dbTableName: string): string;
+
+ generatedColumnQuery(): IGeneratedColumnQueryInterface;
+
+ convertFormulaToGeneratedColumn(
+ expression: string,
+ context: IFormulaConversionContext
+ ): IFormulaConversionResult;
+
+ selectQuery(): ISelectQueryInterface;
+
+ convertFormulaToSelectQuery(
+ expression: string,
+ context: ISelectFormulaConversionContext
+ ): IFieldSelectName;
+
+ generateDatabaseViewName(tableId: string): string;
+ createDatabaseView(
+ table: TableDomain,
+ qb: Knex.QueryBuilder,
+ options?: { materialized?: boolean }
+ ): string[];
+ recreateDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[];
+ dropDatabaseView(tableId: string): string[];
+ refreshDatabaseView(tableId: string, options?: { concurrently?: boolean }): string | undefined;
+
+ createMaterializedView(table: TableDomain, qb: Knex.QueryBuilder): string;
+ dropMaterializedView(tableId: string): string;
}
diff --git a/apps/nestjs-backend/src/db-provider/db.provider.ts b/apps/nestjs-backend/src/db-provider/db.provider.ts
index 72633f81d3..2ad6830f0c 100644
--- a/apps/nestjs-backend/src/db-provider/db.provider.ts
+++ b/apps/nestjs-backend/src/db-provider/db.provider.ts
@@ -15,7 +15,6 @@ export const DbProvider: Provider = {
provide: DB_PROVIDER_SYMBOL,
useFactory: (knex: Knex) => {
const driverClient = getDriverName(knex);
-
switch (driverClient) {
case DriverClient.Sqlite:
return new SqliteProvider(knex);
diff --git a/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.interface.ts b/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.interface.ts
new file mode 100644
index 0000000000..1a37cedb6a
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.interface.ts
@@ -0,0 +1,28 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type { Knex } from 'knex';
+
+/**
+ * Operation types for database column dropping
+ */
+export enum DropColumnOperationType {
+ /** Complete field deletion - remove field and all related foreign keys/tables */
+ DELETE_FIELD = 'DELETE_FIELD',
+ /** Field type conversion - only remove field columns, preserve foreign key relationships */
+ CONVERT_FIELD = 'CONVERT_FIELD',
+ /** Delete symmetric field in bidirectional to unidirectional conversion - preserve foreign keys for main field */
+ DELETE_SYMMETRIC_FIELD = 'DELETE_SYMMETRIC_FIELD',
+}
+
+/**
+ * Context interface for database column dropping
+ */
+export interface IDropDatabaseColumnContext {
+ /** Table name */
+ tableName: string;
+ /** Knex instance for building queries */
+ knex: Knex;
+ /** Link context for link field operations */
+ linkContext?: { tableId: string; tableNameMap: Map };
+ /** Operation type to determine deletion strategy */
+ operationType?: DropColumnOperationType;
+}
diff --git a/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.postgres.ts b/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.postgres.ts
new file mode 100644
index 0000000000..4c7ae4146e
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.postgres.ts
@@ -0,0 +1,243 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { Relationship } from '@teable/core';
+import type {
+ AttachmentFieldCore,
+ AutoNumberFieldCore,
+ CheckboxFieldCore,
+ CreatedByFieldCore,
+ CreatedTimeFieldCore,
+ DateFieldCore,
+ FormulaFieldCore,
+ LastModifiedByFieldCore,
+ LastModifiedTimeFieldCore,
+ LinkFieldCore,
+ LongTextFieldCore,
+ MultipleSelectFieldCore,
+ NumberFieldCore,
+ RatingFieldCore,
+ RollupFieldCore,
+ ConditionalRollupFieldCore,
+ SingleLineTextFieldCore,
+ SingleSelectFieldCore,
+ UserFieldCore,
+ IFieldVisitor,
+ FieldCore,
+ ILinkFieldOptions,
+ ButtonFieldCore,
+} from '@teable/core';
+import { DropColumnOperationType } from './drop-database-column-field-visitor.interface';
+import type { IDropDatabaseColumnContext } from './drop-database-column-field-visitor.interface';
+
+/**
+ * PostgreSQL implementation of database column drop visitor.
+ */
+export class DropPostgresDatabaseColumnFieldVisitor implements IFieldVisitor {
+ constructor(private readonly context: IDropDatabaseColumnContext) {}
+
+ private dropStandardColumn(field: FieldCore): string[] {
+ // Get all column names for this field
+ const columnNames = field.dbFieldNames;
+ const queries: string[] = [];
+
+ for (const columnName of columnNames) {
+ // Use CASCADE to automatically drop dependent objects (like generated columns)
+ // This is safe because we handle application-level dependencies separately
+ const dropQuery = this.context.knex
+ .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [
+ this.context.tableName,
+ columnName,
+ ])
+ .toQuery();
+
+ queries.push(dropQuery);
+ }
+
+ return queries;
+ }
+
+ private dropFormulaColumns(field: FormulaFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ private dropForeignKeyForLinkField(field: LinkFieldCore): string[] {
+ const options = field.options as ILinkFieldOptions;
+ const { fkHostTableName, relationship, selfKeyName, foreignKeyName, isOneWay } = options;
+ const queries: string[] = [];
+
+ // Check operation type - only drop foreign keys for complete field deletion
+ const operationType = this.context.operationType || DropColumnOperationType.DELETE_FIELD;
+
+ // For field conversion or symmetric field deletion, preserve foreign key relationships
+ // as they may still be needed by other fields
+ if (
+ operationType === DropColumnOperationType.CONVERT_FIELD ||
+ operationType === DropColumnOperationType.DELETE_SYMMETRIC_FIELD
+ ) {
+ return queries; // Return empty array - don't drop foreign keys
+ }
+
+ // Helper function to drop table
+ const dropTable = (tableName: string): string => {
+ return this.context.knex.raw('DROP TABLE IF EXISTS ?? CASCADE', [tableName]).toQuery();
+ };
+
+ // Helper function to drop column with index and order column
+ const dropColumn = (tableName: string, columnName: string): string[] => {
+ const dropQueries: string[] = [];
+
+ // Drop index first
+ dropQueries.push(
+ this.context.knex.raw('DROP INDEX IF EXISTS ??', [`index_${columnName}`]).toQuery()
+ );
+
+ // Drop main column
+ dropQueries.push(
+ this.context.knex
+ .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [tableName, columnName])
+ .toQuery()
+ );
+
+ // Drop order column if it exists
+ dropQueries.push(
+ this.context.knex
+ .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [
+ tableName,
+ `${columnName}_order`,
+ ])
+ .toQuery()
+ );
+
+ return dropQueries;
+ };
+
+ // Handle different relationship types - only for complete field deletion
+ if (relationship === Relationship.ManyMany && fkHostTableName.includes('junction_')) {
+ queries.push(dropTable(fkHostTableName));
+ }
+
+ if (relationship === Relationship.ManyOne) {
+ queries.push(...dropColumn(fkHostTableName, foreignKeyName));
+ }
+
+ if (relationship === Relationship.OneMany) {
+ if (isOneWay && fkHostTableName.includes('junction_')) {
+ queries.push(dropTable(fkHostTableName));
+ } else if (!isOneWay) {
+ // For non-one-way OneMany relationships, drop the selfKeyName column and its order column
+ queries.push(...dropColumn(fkHostTableName, selfKeyName));
+ }
+ }
+
+ if (relationship === Relationship.OneOne) {
+ const columnToDrop = foreignKeyName === '__id' ? selfKeyName : foreignKeyName;
+ queries.push(...dropColumn(fkHostTableName, columnToDrop));
+ }
+
+ return queries;
+ }
+
+ // Basic field types
+ visitNumberField(field: NumberFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitSingleLineTextField(field: SingleLineTextFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLongTextField(field: LongTextFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitAttachmentField(field: AttachmentFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitCheckboxField(field: CheckboxFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitDateField(field: DateFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitRatingField(field: RatingFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitAutoNumberField(field: AutoNumberFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLinkField(field: LinkFieldCore): string[] {
+ const opts = field.options as ILinkFieldOptions;
+ const rel = opts?.relationship;
+ const inferredFkName =
+ opts?.foreignKeyName ??
+ (rel === Relationship.ManyOne || rel === Relationship.OneOne ? field.dbFieldName : undefined);
+ const inferredSelfName =
+ opts?.selfKeyName ??
+ (rel === Relationship.OneMany && opts?.isOneWay === false ? field.dbFieldName : undefined);
+ const conflictNames = new Set();
+ if (inferredFkName) conflictNames.add(inferredFkName);
+ if (inferredSelfName) conflictNames.add(inferredSelfName);
+
+ const queries: string[] = [];
+ // Drop the separate base column only if it does not conflict with FK columns
+ if (!conflictNames.has(field.dbFieldName)) {
+ queries.push(...this.dropStandardColumn(field));
+ }
+
+ // Always drop FK/junction artifacts for link fields
+ queries.push(...this.dropForeignKeyForLinkField(field));
+ return queries;
+ }
+
+ visitRollupField(field: RollupFieldCore): string[] {
+ // Drop underlying base column for rollup fields
+ return this.dropStandardColumn(field);
+ }
+
+ visitConditionalRollupField(field: ConditionalRollupFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ // Select field types
+ visitSingleSelectField(field: SingleSelectFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitMultipleSelectField(field: MultipleSelectFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitButtonField(field: ButtonFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ // Formula field types
+ visitFormulaField(field: FormulaFieldCore): string[] {
+ return this.dropFormulaColumns(field);
+ }
+
+ visitCreatedTimeField(field: CreatedTimeFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ // User field types
+ visitUserField(field: UserFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitCreatedByField(field: CreatedByFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLastModifiedByField(field: LastModifiedByFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.sqlite.ts b/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.sqlite.ts
new file mode 100644
index 0000000000..fbd1501051
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/drop-database-column-query/drop-database-column-field-visitor.sqlite.ts
@@ -0,0 +1,226 @@
+import { Relationship } from '@teable/core';
+import type {
+ AttachmentFieldCore,
+ AutoNumberFieldCore,
+ CheckboxFieldCore,
+ CreatedByFieldCore,
+ CreatedTimeFieldCore,
+ DateFieldCore,
+ FormulaFieldCore,
+ LastModifiedByFieldCore,
+ LastModifiedTimeFieldCore,
+ LinkFieldCore,
+ LongTextFieldCore,
+ MultipleSelectFieldCore,
+ NumberFieldCore,
+ RatingFieldCore,
+ RollupFieldCore,
+ ConditionalRollupFieldCore,
+ SingleLineTextFieldCore,
+ SingleSelectFieldCore,
+ UserFieldCore,
+ IFieldVisitor,
+ FieldCore,
+ ILinkFieldOptions,
+ ButtonFieldCore,
+} from '@teable/core';
+import type { IDropDatabaseColumnContext } from './drop-database-column-field-visitor.interface';
+import { DropColumnOperationType } from './drop-database-column-field-visitor.interface';
+
+/**
+ * SQLite implementation of database column drop visitor.
+ */
+export class DropSqliteDatabaseColumnFieldVisitor implements IFieldVisitor {
+ constructor(private readonly context: IDropDatabaseColumnContext) {}
+
+ private dropStandardColumn(field: FieldCore): string[] {
+ // Get all column names for this field
+ const columnNames = field.dbFieldNames;
+ const queries: string[] = [];
+
+ for (const columnName of columnNames) {
+ const dropQuery = this.context.knex
+ .raw('ALTER TABLE ?? DROP COLUMN ??', [this.context.tableName, columnName])
+ .toQuery();
+
+ queries.push(dropQuery);
+ }
+
+ return queries;
+ }
+
+ private dropFormulaColumns(field: FormulaFieldCore): string[] {
+ // Align with Postgres: drop the physical column representing the formula
+ // regardless of whether it was persisted as a generated column or not.
+ return this.dropStandardColumn(field);
+ }
+
+ // eslint-disable-next-line sonarjs/cognitive-complexity
+ private dropForeignKeyForLinkField(field: LinkFieldCore): string[] {
+ const options = field.options as ILinkFieldOptions;
+ const { fkHostTableName, relationship, selfKeyName, foreignKeyName, isOneWay } = options;
+ const queries: string[] = [];
+
+ // Check operation type - only drop foreign keys for complete field deletion
+ const operationType = this.context.operationType || DropColumnOperationType.DELETE_FIELD;
+
+ // For field conversion or symmetric field deletion, preserve foreign key relationships
+ // as they may still be needed by other fields
+ if (
+ operationType === DropColumnOperationType.CONVERT_FIELD ||
+ operationType === DropColumnOperationType.DELETE_SYMMETRIC_FIELD
+ ) {
+ return queries; // Return empty array - don't drop foreign keys
+ }
+
+ // Helper function to drop table
+ const dropTable = (tableName: string): string => {
+ return this.context.knex.raw('DROP TABLE IF EXISTS ??', [tableName]).toQuery();
+ };
+
+ // Helper function to drop column with index
+ const dropColumn = (tableName: string, columnName: string): string[] => {
+ const dropQueries: string[] = [];
+
+ // Drop index first
+ dropQueries.push(
+ this.context.knex.raw('DROP INDEX IF EXISTS ??', [`index_${columnName}`]).toQuery()
+ );
+
+ // Drop column
+ dropQueries.push(
+ this.context.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [tableName, columnName]).toQuery()
+ );
+
+ return dropQueries;
+ };
+
+ // Handle different relationship types
+ if (relationship === Relationship.ManyMany && fkHostTableName.includes('junction_')) {
+ queries.push(dropTable(fkHostTableName));
+ }
+
+ if (relationship === Relationship.ManyOne) {
+ queries.push(...dropColumn(fkHostTableName, foreignKeyName));
+ }
+
+ if (relationship === Relationship.OneMany) {
+ if (isOneWay) {
+ if (fkHostTableName.includes('junction_')) {
+ queries.push(dropTable(fkHostTableName));
+ }
+ } else {
+ queries.push(...dropColumn(fkHostTableName, selfKeyName));
+ }
+ }
+
+ if (relationship === Relationship.OneOne) {
+ const columnToDrop = foreignKeyName === '__id' ? selfKeyName : foreignKeyName;
+ queries.push(...dropColumn(fkHostTableName, columnToDrop));
+ }
+
+ return queries;
+ }
+
+ // Basic field types
+ visitNumberField(field: NumberFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitSingleLineTextField(field: SingleLineTextFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLongTextField(field: LongTextFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitAttachmentField(field: AttachmentFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitCheckboxField(field: CheckboxFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitDateField(field: DateFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitRatingField(field: RatingFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitAutoNumberField(field: AutoNumberFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLinkField(field: LinkFieldCore): string[] {
+ const opts = field.options as ILinkFieldOptions;
+ const rel = opts?.relationship;
+ const inferredFkName =
+ opts?.foreignKeyName ??
+ (rel === Relationship.ManyOne || rel === Relationship.OneOne ? field.dbFieldName : undefined);
+ const inferredSelfName =
+ opts?.selfKeyName ??
+ (rel === Relationship.OneMany && opts?.isOneWay === false ? field.dbFieldName : undefined);
+ const conflictNames = new Set();
+ if (inferredFkName) conflictNames.add(inferredFkName);
+ if (inferredSelfName) conflictNames.add(inferredSelfName);
+
+ const queries: string[] = [];
+ if (!conflictNames.has(field.dbFieldName)) {
+ queries.push(...this.dropStandardColumn(field));
+ }
+ queries.push(...this.dropForeignKeyForLinkField(field));
+ return queries;
+ }
+
+ visitRollupField(field: RollupFieldCore): string[] {
+ // Drop underlying base column for rollup fields
+ return this.dropStandardColumn(field);
+ }
+
+ visitConditionalRollupField(field: ConditionalRollupFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ // Select field types
+ visitSingleSelectField(field: SingleSelectFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitMultipleSelectField(field: MultipleSelectFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitButtonField(field: ButtonFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ // Formula field types
+ visitFormulaField(field: FormulaFieldCore): string[] {
+ return this.dropFormulaColumns(field);
+ }
+
+ visitCreatedTimeField(field: CreatedTimeFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLastModifiedTimeField(field: LastModifiedTimeFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ // User field types
+ visitUserField(field: UserFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitCreatedByField(field: CreatedByFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+
+ visitLastModifiedByField(field: LastModifiedByFieldCore): string[] {
+ return this.dropStandardColumn(field);
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/drop-database-column-query/index.ts b/apps/nestjs-backend/src/db-provider/drop-database-column-query/index.ts
new file mode 100644
index 0000000000..299bf6b3a9
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/drop-database-column-query/index.ts
@@ -0,0 +1,3 @@
+export * from './drop-database-column-field-visitor.interface';
+export * from './drop-database-column-field-visitor.postgres';
+export * from './drop-database-column-field-visitor.sqlite';
diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/abstract.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/abstract.ts
new file mode 100644
index 0000000000..97a0f4a275
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/duplicate-table/abstract.ts
@@ -0,0 +1,13 @@
+import type { Knex } from 'knex';
+
+export abstract class DuplicateTableQueryAbstract {
+ constructor(protected readonly queryBuilder: Knex.QueryBuilder) {}
+
+ abstract duplicateTableData(
+ sourceTable: string,
+ targetTable: string,
+ newColumns: string[],
+ oldColumns: string[],
+ crossBaseLinkDbFieldNames: { dbFieldName: string; isMultipleCellValue: boolean }[]
+ ): Knex.QueryBuilder;
+}
diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.abstract.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.abstract.ts
new file mode 100644
index 0000000000..010d58b5d3
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.abstract.ts
@@ -0,0 +1,13 @@
+import type { Knex } from 'knex';
+
+export abstract class DuplicateAttachmentTableQueryAbstract {
+ constructor(protected readonly queryBuilder: Knex.QueryBuilder) {}
+
+ abstract duplicateAttachmentTable(
+ sourceTableId: string,
+ targetTableId: string,
+ sourceFieldId: string,
+ targetFieldId: string,
+ userId: string
+ ): Knex.QueryBuilder;
+}
diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.postgres.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.postgres.ts
new file mode 100644
index 0000000000..5df17486bb
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.postgres.ts
@@ -0,0 +1,58 @@
+import type { Knex } from 'knex';
+import { DuplicateAttachmentTableQueryAbstract } from './duplicate-attachment-table-query.abstract';
+
+export class DuplicateAttachmentTableQueryPostgres extends DuplicateAttachmentTableQueryAbstract {
+ protected knex: Knex.Client;
+ constructor(queryBuilder: Knex.QueryBuilder) {
+ super(queryBuilder);
+ this.knex = queryBuilder.client;
+ }
+
+ duplicateAttachmentTable(
+ sourceTableId: string,
+ targetTableId: string,
+ sourceFieldId: string,
+ targetFieldId: string,
+ userId: string
+ ) {
+ const attachmentTableDbName = 'attachments_table';
+ const targetColumns = [
+ 'id',
+ 'attachment_id',
+ 'name',
+ 'token',
+ 'record_id',
+ 'table_id',
+ 'field_id',
+ 'created_by',
+ ];
+
+ const sourceColumns = [
+ this.knex.raw(
+ `(
+ 'cm' ||
+ substr(md5(random()::text || clock_timestamp()::text), 1, 8) ||
+ substr(md5(random()::text), 1, 15)
+ )`
+ ),
+ 'attachment_id',
+ 'name',
+ 'token',
+ 'record_id',
+ this.knex.raw(`'${targetTableId}' AS table_id`),
+ this.knex.raw(`'${targetFieldId}' AS field_id`),
+ this.knex.raw(`'${userId}' AS created_by`),
+ ];
+
+ const newColumnList = targetColumns.map((col) => `"${col}"`).join(', ');
+ const oldColumnList = sourceColumns
+ .map((col) => {
+ return typeof col === 'string' ? `"${col}"` : col;
+ })
+ .join(', ');
+ return this.knex.raw(
+ `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? WHERE field_id = ? and table_id = ?`,
+ [attachmentTableDbName, attachmentTableDbName, sourceFieldId, sourceTableId]
+ );
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.sqlite.ts
new file mode 100644
index 0000000000..09869b0b7b
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-attachment-table-query.sqlite.ts
@@ -0,0 +1,56 @@
+import type { Knex } from 'knex';
+import { DuplicateAttachmentTableQueryAbstract } from './duplicate-attachment-table-query.abstract';
+
+export class DuplicateAttachmentTableQuerySqlite extends DuplicateAttachmentTableQueryAbstract {
+ protected knex: Knex.Client;
+ constructor(queryBuilder: Knex.QueryBuilder) {
+ super(queryBuilder);
+ this.knex = queryBuilder.client;
+ }
+
+ duplicateAttachmentTable(
+ sourceTableId: string,
+ targetTableId: string,
+ sourceFieldId: string,
+ targetFieldId: string,
+ userId: string
+ ) {
+ const attachmentTableDbName = 'attachments_table';
+ const targetColumns = [
+ 'id',
+ 'attachment_id',
+ 'name',
+ 'token',
+ 'record_id',
+ 'table_id',
+ 'field_id',
+ 'created_by',
+ ];
+
+ const sourceColumns = [
+ this.knex.raw(`(
+ 'cm' ||
+ substr(hex(randomblob(4)), 1, 8) ||
+ substr(hex(randomblob(8)), 1, 15)
+ )`),
+ 'attachment_id',
+ 'name',
+ 'token',
+ 'record_id',
+ this.knex.raw(`'${targetTableId}' AS table_id`),
+ this.knex.raw(`'${targetFieldId}' AS field_id`),
+ this.knex.raw(`'${userId}' AS created_by`),
+ ];
+
+ const newColumnList = targetColumns.map((col) => `"${col}"`).join(', ');
+ const oldColumnList = sourceColumns
+ .map((col) => {
+ return typeof col === 'string' ? `"${col}"` : col;
+ })
+ .join(', ');
+ return this.knex.raw(
+ `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? WHERE field_id = ? and table_id = ?`,
+ [attachmentTableDbName, attachmentTableDbName, sourceFieldId, sourceTableId]
+ );
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.postgres.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.postgres.ts
new file mode 100644
index 0000000000..d3a7426e74
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.postgres.ts
@@ -0,0 +1,45 @@
+import type { Knex } from 'knex';
+import { DuplicateTableQueryAbstract } from './abstract';
+
+export class DuplicateTableQueryPostgres extends DuplicateTableQueryAbstract {
+ protected knex: Knex.Client;
+ constructor(queryBuilder: Knex.QueryBuilder) {
+ super(queryBuilder);
+ this.knex = queryBuilder.client;
+ }
+
+ duplicateTableData(
+ sourceTable: string,
+ targetTable: string,
+ newColumns: string[],
+ oldColumns: string[],
+ crossBaseLinkDbFieldNames: { dbFieldName: string; isMultipleCellValue: boolean }[]
+ ) {
+ const newColumnList = newColumns.map((col) => `"${col}"`).join(', ');
+ const oldColumnList = oldColumns
+ .map((col) => {
+ if (col === '__version') {
+ return '1 AS "__version"';
+ }
+ // cross base link field should transform to text from json
+ if (crossBaseLinkDbFieldNames.map(({ dbFieldName }) => dbFieldName).includes(col)) {
+ const isMultipleCellValue = crossBaseLinkDbFieldNames.find(
+ ({ dbFieldName }) => dbFieldName === col
+ )?.isMultipleCellValue;
+ return !isMultipleCellValue
+ ? `"${col}" ->> 'title' as "${col}"`
+ : `CASE
+ WHEN "${col}" IS NULL THEN NULL
+ ELSE (SELECT string_agg(elem ->> 'title', ', ')
+ FROM json_array_elements(CAST("${col}" AS json)) AS elem)
+ END as "${col}"`;
+ }
+ return `"${col}"`;
+ })
+ .join(', ');
+ return this.knex.raw(
+ `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? ORDER BY __auto_number`,
+ [targetTable, sourceTable]
+ );
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.sqlite.ts
new file mode 100644
index 0000000000..fb24e06336
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/duplicate-table/duplicate-query.sqlite.ts
@@ -0,0 +1,47 @@
+import type { Knex } from 'knex';
+import { DuplicateTableQueryAbstract } from './abstract';
+
+export class DuplicateTableQuerySqlite extends DuplicateTableQueryAbstract {
+ protected knex: Knex.Client;
+ constructor(queryBuilder: Knex.QueryBuilder) {
+ super(queryBuilder);
+ this.knex = queryBuilder.client;
+ }
+
+ duplicateTableData(
+ sourceTable: string,
+ targetTable: string,
+ newColumns: string[],
+ oldColumns: string[],
+ crossBaseLinkDbFieldNames: { dbFieldName: string; isMultipleCellValue: boolean }[]
+ ) {
+ const newColumnList = newColumns.map((col) => `"${col}"`).join(', ');
+ const oldColumnList = oldColumns
+ .map((col) => {
+ if (col === '__version') {
+ return '1 AS "__version"';
+ }
+ // cross base link field should transform to text from json
+ if (crossBaseLinkDbFieldNames.map(({ dbFieldName }) => dbFieldName).includes(col)) {
+ const isMultipleCellValue = crossBaseLinkDbFieldNames.find(
+ ({ dbFieldName }) => dbFieldName === col
+ )?.isMultipleCellValue;
+ return !isMultipleCellValue
+ ? `json_extract("${col}", '$.title') as "${col}"`
+ : `CASE
+ WHEN "${col}" IS NULL THEN NULL
+ ELSE (
+ SELECT group_concat(json_extract(value, '$.title'), ',')
+ FROM json_each("${col}")
+ )
+ END as "${col}"`;
+ }
+ return `"${col}"`;
+ })
+ .join(', ');
+ return this.knex.raw(
+ `INSERT INTO ?? (${newColumnList}) SELECT ${oldColumnList} FROM ?? ORDER BY __auto_number`,
+ [targetTable, sourceTable]
+ );
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts
new file mode 100644
index 0000000000..24d0334f52
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/filter-query/__tests__/field-reference.spec.ts
@@ -0,0 +1,447 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+/* eslint-disable @typescript-eslint/naming-convention */
+import {
+ CellValueType,
+ CheckboxFieldCore,
+ DateFieldCore,
+ DateFormattingPreset,
+ DriverClient,
+ FieldType,
+ NumberFieldCore,
+ SingleLineTextFieldCore,
+ TimeFormatting,
+ UserFieldCore,
+ defaultUserFieldOptions,
+ filterSchema,
+ hasAnyOf,
+ is,
+ isExactly,
+} from '@teable/core';
+import type { FieldCore, IFilter } from '@teable/core';
+import knex from 'knex';
+import type { IDbProvider } from '../../db.provider.interface';
+import { FilterQueryPostgres } from '../postgres/filter-query.postgres';
+
+type FieldPair = {
+ label: string;
+ field: FieldCore;
+ reference: FieldCore;
+ expectedSql: RegExp;
+};
+
+const knexBuilder = knex({ client: 'pg' });
+
+const dbProviderStub = { driver: DriverClient.Pg } as unknown as IDbProvider;
+
+function assignBaseField(
+ field: T,
+ params: {
+ id: string;
+ dbFieldName: string;
+ type: FieldType;
+ cellValueType: CellValueType;
+ options: T['options'];
+ isMultipleCellValue?: boolean;
+ }
+): T {
+ field.id = params.id;
+ field.name = params.id;
+ field.dbFieldName = params.dbFieldName;
+ field.type = params.type;
+ field.options = params.options;
+ field.cellValueType = params.cellValueType;
+ field.isMultipleCellValue = params.isMultipleCellValue ?? false;
+ field.isLookup = false;
+ field.updateDbFieldType();
+ return field;
+}
+
+function createNumberField(id: string, dbFieldName: string): NumberFieldCore {
+ return assignBaseField(new NumberFieldCore(), {
+ id,
+ dbFieldName,
+ type: FieldType.Number,
+ cellValueType: CellValueType.Number,
+ options: NumberFieldCore.defaultOptions(),
+ });
+}
+
+function createNumberArrayField(id: string, dbFieldName: string): NumberFieldCore {
+ const field = createNumberField(id, dbFieldName);
+ field.isMultipleCellValue = true;
+ return field;
+}
+
+function createTextField(id: string, dbFieldName: string): SingleLineTextFieldCore {
+ return assignBaseField(new SingleLineTextFieldCore(), {
+ id,
+ dbFieldName,
+ type: FieldType.SingleLineText,
+ cellValueType: CellValueType.String,
+ options: SingleLineTextFieldCore.defaultOptions(),
+ });
+}
+
+function createDateField(id: string, dbFieldName: string): DateFieldCore {
+ const options = DateFieldCore.defaultOptions();
+ options.formatting = {
+ date: DateFormattingPreset.ISO,
+ time: TimeFormatting.None,
+ timeZone: 'UTC',
+ };
+ return assignBaseField(new DateFieldCore(), {
+ id,
+ dbFieldName,
+ type: FieldType.Date,
+ cellValueType: CellValueType.DateTime,
+ options,
+ });
+}
+
+function createCheckboxField(id: string, dbFieldName: string): CheckboxFieldCore {
+ return assignBaseField(new CheckboxFieldCore(), {
+ id,
+ dbFieldName,
+ type: FieldType.Checkbox,
+ cellValueType: CellValueType.Boolean,
+ options: CheckboxFieldCore.defaultOptions(),
+ });
+}
+
+function createUserField(
+ id: string,
+ dbFieldName: string,
+ isMultipleCellValue: boolean
+): UserFieldCore {
+ return assignBaseField(new UserFieldCore(), {
+ id,
+ dbFieldName,
+ type: FieldType.User,
+ cellValueType: CellValueType.String,
+ options: { ...defaultUserFieldOptions, isMultiple: isMultipleCellValue },
+ isMultipleCellValue,
+ });
+}
+
+const cases: FieldPair[] = [
+ {
+ label: 'number field',
+ field: createNumberField('fld_number', 'number_col'),
+ reference: createNumberField('fld_number_ref', 'number_ref'),
+ expectedSql: /"main"."number_col" = "main"."number_ref"/i,
+ },
+ {
+ label: 'single line text field',
+ field: createTextField('fld_text', 'text_col'),
+ reference: createTextField('fld_text_ref', 'text_ref'),
+ expectedSql: /"main"."text_col" = "main"."text_ref"/i,
+ },
+ {
+ label: 'date field',
+ field: createDateField('fld_date', 'date_col'),
+ reference: createDateField('fld_date_ref', 'date_ref'),
+ expectedSql:
+ /DATE_TRUNC\('day', \("main"\."date_col"\) AT TIME ZONE 'UTC'\) = DATE_TRUNC\('day', \("main"\."date_ref"\) AT TIME ZONE 'UTC'\)/,
+ },
+ {
+ label: 'checkbox field',
+ field: createCheckboxField('fld_checkbox', 'checkbox_col'),
+ reference: createCheckboxField('fld_checkbox_ref', 'checkbox_ref'),
+ expectedSql: /"main"."checkbox_col" = "main"."checkbox_ref"/i,
+ },
+ {
+ label: 'user field',
+ field: createUserField('fld_user', 'user_col', false),
+ reference: createUserField('fld_user_ref', 'user_ref', false),
+ expectedSql:
+ /jsonb_extract_path_text\("main"\."user_col"::jsonb, 'id'\) = jsonb_extract_path_text\("main"\."user_ref"::jsonb, 'id'\)/i,
+ },
+];
+
+describe('field reference filters', () => {
+ it.each(cases)('supports field reference for %s', ({ field, reference, expectedSql }) => {
+ const filter: IFilter = {
+ conjunction: 'and',
+ filterSet: [
+ {
+ fieldId: field.id,
+ operator: is.value,
+ value: { type: 'field', fieldId: reference.id },
+ },
+ ],
+ } as const;
+
+ const parseResult = filterSchema.safeParse(filter);
+ expect(parseResult.success).toBe(true);
+
+ const qb = knexBuilder('main_table as main');
+
+ const selectionEntries: [string, string][] = [
+ [field.id, `"main"."${field.dbFieldName}"`],
+ [reference.id, `"main"."${reference.dbFieldName}"`],
+ ];
+
+ const selectionMap = new Map(selectionEntries);
+ const filterQuery = new FilterQueryPostgres(
+ qb,
+ {
+ [field.id]: field,
+ [reference.id]: reference,
+ },
+ filter,
+ undefined,
+ dbProviderStub,
+ {
+ selectionMap,
+ fieldReferenceSelectionMap: new Map(selectionEntries),
+ fieldReferenceFieldMap: new Map([
+ [field.id, field],
+ [reference.id, reference],
+ ]),
+ }
+ );
+
+ expect(() => filterQuery.appendQueryBuilder()).not.toThrow();
+
+ const sql = qb.toQuery().replace(/\s+/g, ' ');
+ expect(sql).toMatch(expectedSql);
+ });
+
+ it('supports hasAnyOf against multi-user field references', () => {
+ const field = createUserField('fld_multi_user', 'multi_user_col', true);
+ const reference = createUserField('fld_multi_user_ref', 'multi_user_ref_col', true);
+
+ const filter: IFilter = {
+ conjunction: 'and',
+ filterSet: [
+ {
+ fieldId: field.id,
+ operator: hasAnyOf.value,
+ value: { type: 'field', fieldId: reference.id },
+ },
+ ],
+ } as const;
+
+ const qb = knexBuilder('main_table as main');
+
+ const selectionEntries: [string, string][] = [
+ [field.id, `"main"."${field.dbFieldName}"`],
+ [reference.id, `"main"."${reference.dbFieldName}"`],
+ ];
+
+ const filterQuery = new FilterQueryPostgres(
+ qb,
+ {
+ [field.id]: field,
+ [reference.id]: reference,
+ },
+ filter,
+ undefined,
+ dbProviderStub,
+ {
+ selectionMap: new Map(selectionEntries),
+ fieldReferenceSelectionMap: new Map(selectionEntries),
+ fieldReferenceFieldMap: new Map([
+ [field.id, field],
+ [reference.id, reference],
+ ]),
+ }
+ );
+
+ expect(() => filterQuery.appendQueryBuilder()).not.toThrow();
+ const sql = qb.toQuery().replace(/\s+/g, ' ');
+ expect(sql).toContain('jsonb_exists_any');
+ expect(sql).toContain('"main"."multi_user_col"');
+ expect(sql).toContain('"main"."multi_user_ref_col"');
+ });
+
+ it('supports isExactly against multi-user field references', () => {
+ const field = createUserField('fld_multi_user_exact', 'multi_user_exact_col', true);
+ const reference = createUserField('fld_multi_user_exact_ref', 'multi_user_exact_ref_col', true);
+
+ const filter: IFilter = {
+ conjunction: 'and',
+ filterSet: [
+ {
+ fieldId: field.id,
+ operator: isExactly.value,
+ value: { type: 'field', fieldId: reference.id },
+ },
+ ],
+ } as const;
+
+ const qb = knexBuilder('main_table as main');
+
+ const selectionEntries: [string, string][] = [
+ [field.id, `"main"."${field.dbFieldName}"`],
+ [reference.id, `"main"."${reference.dbFieldName}"`],
+ ];
+
+ const filterQuery = new FilterQueryPostgres(
+ qb,
+ {
+ [field.id]: field,
+ [reference.id]: reference,
+ },
+ filter,
+ undefined,
+ dbProviderStub,
+ {
+ selectionMap: new Map(selectionEntries),
+ fieldReferenceSelectionMap: new Map(selectionEntries),
+ fieldReferenceFieldMap: new Map([
+ [field.id, field],
+ [reference.id, reference],
+ ]),
+ }
+ );
+
+ expect(() => filterQuery.appendQueryBuilder()).not.toThrow();
+ const sql = qb.toQuery().replace(/\s+/g, ' ');
+ expect(sql).toContain('jsonb_path_query_array(COALESCE("main"."multi_user_exact_col"');
+ expect(sql).toContain('@> jsonb_path_query_array(COALESCE("main"."multi_user_exact_ref_col"');
+ expect(sql).toContain('jsonb_path_query_array(COALESCE("main"."multi_user_exact_ref_col"');
+ expect(sql).toContain('@> jsonb_path_query_array(COALESCE("main"."multi_user_exact_col"');
+ });
+
+ it('supports numeric array comparisons against field references', () => {
+ const field = createNumberArrayField('fld_number_array', 'number_array_col');
+ const reference = createNumberField('fld_threshold_ref', 'threshold_ref_col');
+
+ const filter: IFilter = {
+ conjunction: 'and',
+ filterSet: [
+ {
+ fieldId: field.id,
+ operator: is.value,
+ value: { type: 'field', fieldId: reference.id },
+ },
+ ],
+ } as const;
+
+ const qb = knexBuilder('main_table as main');
+ const selectionEntries: [string, string][] = [
+ [field.id, `"main"."${field.dbFieldName}"`],
+ [reference.id, `"main"."${reference.dbFieldName}"`],
+ ];
+
+ const filterQuery = new FilterQueryPostgres(
+ qb,
+ {
+ [field.id]: field,
+ [reference.id]: reference,
+ },
+ filter,
+ undefined,
+ dbProviderStub,
+ {
+ selectionMap: new Map(selectionEntries),
+ fieldReferenceSelectionMap: new Map(selectionEntries),
+ fieldReferenceFieldMap: new Map([
+ [field.id, field],
+ [reference.id, reference],
+ ]),
+ }
+ );
+
+ expect(() => filterQuery.appendQueryBuilder()).not.toThrow();
+ const sql = qb.toQuery().replace(/\s+/g, ' ');
+ expect(sql).toContain(
+ 'jsonb_exists_any(COALESCE("main"."number_array_col", ' + "'[]'::jsonb), COALESCE"
+ );
+ });
+
+ it('supports numeric array inequality comparisons against field references', () => {
+ const field = createNumberArrayField('fld_number_array_gt', 'number_array_gt_col');
+ const reference = createNumberField('fld_threshold_gt', 'threshold_gt_col');
+
+ const filter: IFilter = {
+ conjunction: 'and',
+ filterSet: [
+ {
+ fieldId: field.id,
+ operator: 'isGreater',
+ value: { type: 'field', fieldId: reference.id },
+ },
+ ],
+ } as const;
+
+ const qb = knexBuilder('main_table as main');
+ const selectionEntries: [string, string][] = [
+ [field.id, `"main"."${field.dbFieldName}"`],
+ [reference.id, `"main"."${reference.dbFieldName}"`],
+ ];
+
+ const filterQuery = new FilterQueryPostgres(
+ qb,
+ {
+ [field.id]: field,
+ [reference.id]: reference,
+ },
+ filter,
+ undefined,
+ dbProviderStub,
+ {
+ selectionMap: new Map(selectionEntries),
+ fieldReferenceSelectionMap: new Map(selectionEntries),
+ fieldReferenceFieldMap: new Map([
+ [field.id, field],
+ [reference.id, reference],
+ ]),
+ }
+ );
+
+ expect(() => filterQuery.appendQueryBuilder()).not.toThrow();
+ const sql = qb.toQuery().replace(/\s+/g, ' ');
+ expect(sql).toContain('jsonb_array_elements_text(COALESCE("main"."number_array_gt_col"');
+ expect(sql).toMatch(/::numeric >/);
+ });
+
+ it('supports numeric array negation comparisons against field references', () => {
+ const field = createNumberArrayField('fld_number_array_not', 'number_array_not_col');
+ const reference = createNumberField('fld_exclude_ref', 'exclude_ref_col');
+
+ const filter: IFilter = {
+ conjunction: 'and',
+ filterSet: [
+ {
+ fieldId: field.id,
+ operator: 'isNot',
+ value: { type: 'field', fieldId: reference.id },
+ },
+ ],
+ } as const;
+
+ const qb = knexBuilder('main_table as main');
+ const selectionEntries: [string, string][] = [
+ [field.id, `"main"."${field.dbFieldName}"`],
+ [reference.id, `"main"."${reference.dbFieldName}"`],
+ ];
+
+ const filterQuery = new FilterQueryPostgres(
+ qb,
+ {
+ [field.id]: field,
+ [reference.id]: reference,
+ },
+ filter,
+ undefined,
+ dbProviderStub,
+ {
+ selectionMap: new Map(selectionEntries),
+ fieldReferenceSelectionMap: new Map(selectionEntries),
+ fieldReferenceFieldMap: new Map([
+ [field.id, field],
+ [reference.id, reference],
+ ]),
+ }
+ );
+
+ expect(() => filterQuery.appendQueryBuilder()).not.toThrow();
+ const sql = qb.toQuery().replace(/\s+/g, ' ');
+ expect(sql).toContain(
+ 'NOT jsonb_exists_any(COALESCE(COALESCE("main"."number_array_not_col",' +
+ " '[]'::jsonb), '[]'::jsonb), COALESCE"
+ );
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.abstract.ts b/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.abstract.ts
index 047b0f8d6f..d6956bf954 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.abstract.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.abstract.ts
@@ -3,16 +3,17 @@ import {
InternalServerErrorException,
NotImplementedException,
} from '@nestjs/common';
-import type { IDateFieldOptions, IDateFilter, IFilterOperator, IFilterValue } from '@teable/core';
import {
CellValueType,
contains,
dateFilterSchema,
+ DateFormattingPreset,
DateUtil,
doesNotContain,
hasAllOf,
hasAnyOf,
hasNoneOf,
+ isNotExactly,
is,
isAfter,
isAnyOf,
@@ -30,27 +31,107 @@ import {
isOnOrBefore,
isWithIn,
literalValueListSchema,
+ isFieldReferenceComparable,
+ isFieldReferenceValue,
+ TimeFormatting,
+} from '@teable/core';
+import type {
+ FieldCore,
+ IDateFieldOptions,
+ IDateFilter,
+ IFilterOperator,
+ IFilterValue,
+ IFieldReferenceValue,
} from '@teable/core';
import type { Dayjs } from 'dayjs';
+import dayjs from 'dayjs';
import type { Knex } from 'knex';
-import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';
+import { escapeLikeWildcards } from '../../utils/sql-like-escape';
+import type { IDbProvider } from '../db.provider.interface';
import type { ICellValueFilterInterface } from './cell-value-filter.interface';
+export class FieldReferenceCompatibilityException extends BadRequestException {
+ static readonly CODE = 'FIELD_REFERENCE_INCOMPATIBLE';
+
+ constructor(sourceField: string, referenceField: string) {
+ super({
+ errorCode: FieldReferenceCompatibilityException.CODE,
+ message: `Field '${referenceField}' is not compatible with '${sourceField}' for filter comparisons`,
+ sourceField,
+ referenceField,
+ });
+ }
+}
+
export abstract class AbstractCellValueFilter implements ICellValueFilterInterface {
protected tableColumnRef: string;
- protected columnName: string;
constructor(
- protected readonly dbTableName: string,
- protected readonly field: IFieldInstance
+ protected readonly field: FieldCore,
+ readonly context?: IRecordQueryFilterContext
) {
- const { dbFieldName } = this.field;
+ const { dbFieldName, id } = field;
+
+ const selection = context?.selectionMap.get(id);
+ if (selection) {
+ this.tableColumnRef = selection as string;
+ } else {
+ this.tableColumnRef = dbFieldName;
+ }
+ }
+
+ protected ensureLiteralValue(value: IFilterValue, operator: IFilterOperator): void {
+ if (isFieldReferenceValue(value)) {
+ throw new BadRequestException(
+ `Operator '${operator}' does not support comparing against another field`
+ );
+ }
+ }
+
+ protected resolveFieldReference(value: IFieldReferenceValue): string {
+ this.getComparableReferenceField(value);
+
+ const referenceMap = this.context?.fieldReferenceSelectionMap;
+ if (!referenceMap) {
+ throw new BadRequestException('Field reference comparisons are not available here');
+ }
+ const reference = referenceMap.get(value.fieldId);
+ if (!reference) {
+ throw new BadRequestException(
+ `Field '${value.fieldId}' is not available for reference comparisons`
+ );
+ }
+ return reference;
+ }
+
+ protected getFieldReferenceMetadata(fieldId: string): FieldCore | undefined {
+ return this.context?.fieldReferenceFieldMap?.get(fieldId);
+ }
+
+ protected getComparableReferenceField(value: IFieldReferenceValue): FieldCore {
+ const referenceField = this.getFieldReferenceMetadata(value.fieldId);
+ if (!referenceField) {
+ throw new BadRequestException(
+ `Field '${value.fieldId}' is not available for reference comparisons`
+ );
+ }
+
+ if (!isFieldReferenceComparable(this.field, referenceField)) {
+ const sourceName = this.field.name ?? this.field.id;
+ const referenceName = referenceField.name ?? referenceField.id;
+ throw new FieldReferenceCompatibilityException(sourceName, referenceName);
+ }
- this.columnName = dbFieldName;
- this.tableColumnRef = `${this.dbTableName}.${dbFieldName}`;
+ return referenceField;
}
- compiler(builderClient: Knex.QueryBuilder, operator: IFilterOperator, value: IFilterValue) {
+ compiler(
+ builderClient: Knex.QueryBuilder,
+ operator: IFilterOperator,
+ value: IFilterValue,
+ dbProvider: IDbProvider
+ ) {
const operatorHandlers = {
[is.value]: this.isOperatorHandler,
[isExactly.value]: this.isExactlyOperatorHandler,
@@ -70,6 +151,7 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
[isNoneOf.value]: this.isNoneOfOperatorHandler,
[hasNoneOf.value]: this.isNoneOfOperatorHandler,
[hasAllOf.value]: this.hasAllOfOperatorHandler,
+ [isNotExactly.value]: this.isNotExactlyOperatorHandler,
[isWithIn.value]: this.isWithInOperatorHandler,
[isEmpty.value]: this.isEmptyOperatorHandler,
[isNotEmpty.value]: this.isNotEmptyOperatorHandler,
@@ -80,24 +162,32 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
throw new InternalServerErrorException(`Unknown operator ${operator} for filter`);
}
- return chosenHandler(builderClient, operator, value);
+ return chosenHandler(builderClient, operator, value, dbProvider);
}
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} = ${ref}`);
+ return builderClient;
+ }
+
const parseValue = this.field.cellValueType === CellValueType.Number ? Number(value) : value;
- builderClient.where(this.columnName, parseValue);
+ builderClient.whereRaw(`${this.tableColumnRef} = ?`, [parseValue]);
return builderClient;
}
isExactlyOperatorHandler(
_builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- _value: IFilterValue
+ _value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
throw new NotImplementedException();
}
@@ -105,93 +195,138 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
abstract isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder;
containsOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.where(this.columnName, 'LIKE', `%${value}%`);
+ this.ensureLiteralValue(value, contains.value);
+ const escapedValue = escapeLikeWildcards(String(value));
+ builderClient.whereRaw(`${this.tableColumnRef} LIKE ? ESCAPE '\\'`, [`%${escapedValue}%`]);
return builderClient;
}
abstract doesNotContainOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder;
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: IFilterValue
+ _operator: IFilterOperator,
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} > ${ref}`);
+ return builderClient;
+ }
const { cellValueType } = this.field;
const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
- builderClient.where(this.columnName, '>', parseValue);
+ builderClient.whereRaw(`${this.tableColumnRef} > ?`, [parseValue]);
return builderClient;
}
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: IFilterValue
+ _operator: IFilterOperator,
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} >= ${ref}`);
+ return builderClient;
+ }
const { cellValueType } = this.field;
const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
- builderClient.where(this.columnName, '>=', parseValue);
+ builderClient.whereRaw(`${this.tableColumnRef} >= ?`, [parseValue]);
return builderClient;
}
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: IFilterValue
+ _operator: IFilterOperator,
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} < ${ref}`);
+ return builderClient;
+ }
const { cellValueType } = this.field;
const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
- builderClient.where(this.columnName, '<', parseValue);
+ builderClient.whereRaw(`${this.tableColumnRef} < ?`, [parseValue]);
return builderClient;
}
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: IFilterValue
+ _operator: IFilterOperator,
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} <= ${ref}`);
+ return builderClient;
+ }
const { cellValueType } = this.field;
const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
- builderClient.where(this.columnName, '<=', parseValue);
+ builderClient.whereRaw(`${this.tableColumnRef} <= ?`, [parseValue]);
return builderClient;
}
isAnyOfOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: IFilterValue
+ _operator: IFilterOperator,
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, isAnyOf.value);
const valueList = literalValueListSchema.parse(value);
- builderClient.whereIn(this.columnName, [...valueList]);
+ builderClient.whereRaw(
+ `${this.tableColumnRef} in (${this.createSqlPlaceholders(valueList)})`,
+ valueList
+ );
return builderClient;
}
abstract isNoneOfOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder;
hasAllOfOperatorHandler(
_builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- _value: IFilterValue
+ _value: IFilterValue,
+ _dbProvider: IDbProvider
+ ): Knex.QueryBuilder {
+ throw new NotImplementedException();
+ }
+
+ isNotExactlyOperatorHandler(
+ _builderClient: Knex.QueryBuilder,
+ _operator: IFilterOperator,
+ _value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
throw new NotImplementedException();
}
@@ -199,7 +334,8 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
isWithInOperatorHandler(
_builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- _value: IFilterValue
+ _value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
throw new NotImplementedException();
}
@@ -207,18 +343,38 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
isEmptyOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- _value: IFilterValue
+ _value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereNull(this.columnName);
+ const tableColumnRef = this.tableColumnRef;
+ const { cellValueType, isStructuredCellValue, isMultipleCellValue } = this.field;
+
+ builderClient.where(function () {
+ this.whereRaw(`${tableColumnRef} is null`);
+
+ if (
+ cellValueType === CellValueType.String &&
+ !isStructuredCellValue &&
+ !isMultipleCellValue
+ ) {
+ this.orWhereRaw(`${tableColumnRef} = ''`);
+ }
+ });
return builderClient;
}
isNotEmptyOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- _value: IFilterValue
+ _value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereNotNull(this.columnName);
+ const { cellValueType, isStructuredCellValue, isMultipleCellValue } = this.field;
+
+ builderClient.whereRaw(`${this.tableColumnRef} is not null`);
+ if (cellValueType === CellValueType.String && !isStructuredCellValue && !isMultipleCellValue) {
+ builderClient.whereRaw(`${this.tableColumnRef} != ''`);
+ }
return builderClient;
}
@@ -234,9 +390,12 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
const { mode, numberOfDays, exactDate } = filterValueByDate;
const {
- formatting: { timeZone },
+ formatting: { timeZone, date: dateFormat, time: timeFormat },
} = dateFieldOptions;
+ // Check if the field has time format configured (not None)
+ const hasTimeFormat = timeFormat && timeFormat !== TimeFormatting.None;
+
const dateUtil = new DateUtil(timeZone);
// Helper function to calculate date range for fixed days like today, tomorrow, etc.
@@ -270,7 +429,34 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
if (!exactDate) {
throw new BadRequestException('Exact date must be entered');
}
- return [dateUtil.date(exactDate).startOf('day'), dateUtil.date(exactDate).endOf('day')];
+
+ const parsedDate = dateUtil.date(exactDate);
+ if (hasTimeFormat) {
+ return [parsedDate, parsedDate];
+ }
+
+ return [parsedDate.startOf('day'), parsedDate.endOf('day')];
+ };
+
+ // Helper function to determine date range for a given exact formatted date.
+ const determineDateRangeForExactFormatDate = (): [Dayjs, Dayjs] => {
+ if (!exactDate) {
+ throw new BadRequestException('Exact date must be entered');
+ }
+
+ const parsedDate = dateUtil.date(exactDate);
+
+ switch (dateFormat) {
+ case DateFormattingPreset.Y:
+ return [parsedDate.startOf('year'), parsedDate.endOf('year')];
+ case DateFormattingPreset.YM:
+ case DateFormattingPreset.M:
+ return [parsedDate.startOf('month'), parsedDate.endOf('month')];
+ case DateFormattingPreset.MD:
+ case DateFormattingPreset.D:
+ default:
+ return [parsedDate.startOf('day'), parsedDate.endOf('day')];
+ }
};
// Helper function to generate offset date range for a given unit (day, week, month, year).
@@ -297,6 +483,55 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
return [startDate, endDate];
};
+ const generateRelativeDateFromCurrentDateRange = (
+ mode: 'current' | 'next' | 'last',
+ unit: 'week' | 'month' | 'year'
+ ): [Dayjs, Dayjs] => {
+ dayjs.locale(dayjs.locale(), {
+ weekStart: 1,
+ });
+ let cursorDate;
+ switch (mode) {
+ case 'current':
+ cursorDate = dateUtil.date();
+ break;
+ case 'next':
+ cursorDate = dateUtil.date().add(1, unit);
+ break;
+ case 'last':
+ cursorDate = dateUtil.date().subtract(1, unit);
+ break;
+ default:
+ cursorDate = dateUtil.date();
+ }
+ return [cursorDate.startOf(unit).startOf('day'), cursorDate.endOf(unit).endOf('day')];
+ };
+
+ // Helper function to determine date range for a custom date range (from exactDate to exactDateEnd).
+ const determineDateRangeForDateRange = (): [Dayjs, Dayjs] => {
+ if (!exactDate) {
+ throw new BadRequestException('Start date must be entered for date range');
+ }
+ const exactDateEnd = filterValueByDate.exactDateEnd;
+ if (!exactDateEnd) {
+ throw new BadRequestException('End date must be entered for date range');
+ }
+
+ const startDate = dateUtil.date(exactDate);
+ const endDate = dateUtil.date(exactDateEnd);
+
+ // Validate that start date is not after end date
+ if (startDate.isAfter(endDate)) {
+ throw new BadRequestException('Start date cannot be after end date');
+ }
+
+ // If field has time format, use exact time from frontend; otherwise use start/end of day
+ if (hasTimeFormat) {
+ return [startDate, endDate];
+ }
+ return [startDate.startOf('day'), endDate.endOf('day')];
+ };
+
// Map of operation functions based on date mode.
const operationMap: Record [Dayjs, Dayjs]> = {
today: () => computeDateRangeForFixedDays('date'),
@@ -309,6 +544,17 @@ export abstract class AbstractCellValueFilter implements ICellValueFilterInterfa
daysAgo: () => calculateDateRangeForOffsetDays(true),
daysFromNow: () => calculateDateRangeForOffsetDays(false),
exactDate: () => determineDateRangeForExactDate(),
+ exactFormatDate: () => determineDateRangeForExactFormatDate(),
+ dateRange: () => determineDateRangeForDateRange(),
+ currentWeek: () => generateRelativeDateFromCurrentDateRange('current', 'week'),
+ currentMonth: () => generateRelativeDateFromCurrentDateRange('current', 'month'),
+ currentYear: () => generateRelativeDateFromCurrentDateRange('current', 'year'),
+ lastWeek: () => generateRelativeDateFromCurrentDateRange('last', 'week'),
+ lastMonth: () => generateRelativeDateFromCurrentDateRange('last', 'month'),
+ lastYear: () => generateRelativeDateFromCurrentDateRange('last', 'year'),
+ nextWeekPeriod: () => generateRelativeDateFromCurrentDateRange('next', 'week'),
+ nextMonthPeriod: () => generateRelativeDateFromCurrentDateRange('next', 'month'),
+ nextYearPeriod: () => generateRelativeDateFromCurrentDateRange('next', 'year'),
pastWeek: () => generateOffsetDateRange(true, 'week', 1),
pastMonth: () => generateOffsetDateRange(true, 'month', 1),
pastYear: () => generateOffsetDateRange(true, 'year', 1),
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.interface.ts b/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.interface.ts
index 9faba8f65c..7e92f13242 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.interface.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/cell-value-filter.interface.ts
@@ -1,16 +1,19 @@
import type { IFilterOperator, IFilterValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../db.provider.interface';
export type ICellValueFilterHandler = (
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
) => Knex.QueryBuilder;
export interface ICellValueFilterInterface {
isOperatorHandler: ICellValueFilterHandler;
isExactlyOperatorHandler: ICellValueFilterHandler;
isNotOperatorHandler: ICellValueFilterHandler;
+ isNotExactlyOperatorHandler: ICellValueFilterHandler;
containsOperatorHandler: ICellValueFilterHandler;
doesNotContainOperatorHandler: ICellValueFilterHandler;
isGreaterOperatorHandler: ICellValueFilterHandler;
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts
index 026a36ea9a..6910423874 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/filter-query.abstract.ts
@@ -1,5 +1,6 @@
-import { BadRequestException, Logger } from '@nestjs/common';
+import { Logger } from '@nestjs/common';
import type {
+ FieldCore,
IConjunction,
IDateTimeFieldOperator,
IFilter,
@@ -7,6 +8,7 @@ import type {
IFilterOperator,
IFilterSet,
ILiteralValueList,
+ IFieldReferenceValue,
} from '@teable/core';
import {
CellValueType,
@@ -14,30 +16,32 @@ import {
FieldType,
getFilterOperatorMapping,
getValidFilterSubOperators,
+ HttpErrorCode,
isEmpty,
isMeTag,
isNotEmpty,
+ isFieldReferenceValue,
} from '@teable/core';
import type { Knex } from 'knex';
-import { get, includes, invert, isObject } from 'lodash';
-import type { IFieldInstance } from '../../features/field/model/factory';
-import type { IFilterQueryExtra } from '../db.provider.interface';
+import { includes, invert, isObject } from 'lodash';
+import { CustomHttpException } from '../../custom.exception';
+import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';
+import type { IDbProvider, IFilterQueryExtra } from '../db.provider.interface';
import type { AbstractCellValueFilter } from './cell-value-filter.abstract';
+import { FieldReferenceCompatibilityException } from './cell-value-filter.abstract';
import type { IFilterQueryInterface } from './filter-query.interface';
export abstract class AbstractFilterQuery implements IFilterQueryInterface {
private logger = new Logger(AbstractFilterQuery.name);
- protected _table: string;
-
constructor(
protected readonly originQueryBuilder: Knex.QueryBuilder,
- protected readonly fields?: { [fieldId: string]: IFieldInstance },
+ protected readonly fields?: { [fieldId: string]: FieldCore },
protected readonly filter?: IFilter,
- protected readonly extra?: IFilterQueryExtra
- ) {
- this._table = get(originQueryBuilder, ['_single', 'table']);
- }
+ protected readonly extra?: IFilterQueryExtra,
+ protected readonly dbProvider?: IDbProvider,
+ protected readonly context?: IRecordQueryFilterContext
+ ) {}
appendQueryBuilder(): Knex.QueryBuilder {
this.preProcessRemoveNullAndReplaceMe(this.filter);
@@ -54,16 +58,17 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface {
return queryBuilder;
}
const { filterSet, conjunction } = filter;
-
- filterSet.forEach((filterItem) => {
- if ('fieldId' in filterItem) {
- this.parseFilter(queryBuilder, filterItem as IFilterItem, conjunction);
- } else {
- queryBuilder = queryBuilder[parentConjunction || conjunction];
- queryBuilder.where((builder) => {
- this.parseFilters(builder, filterItem as IFilterSet, conjunction);
- });
- }
+ queryBuilder.where((filterBuilder) => {
+ filterSet.forEach((filterItem) => {
+ if ('fieldId' in filterItem) {
+ this.parseFilter(filterBuilder, filterItem as IFilterItem, conjunction);
+ } else {
+ filterBuilder = filterBuilder[parentConjunction || conjunction];
+ filterBuilder.where((builder) => {
+ this.parseFilters(builder, filterItem as IFilterSet, conjunction);
+ });
+ }
+ });
});
return queryBuilder;
@@ -89,8 +94,29 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface {
}
if (!includes(validFilterOperators, convertOperator)) {
- throw new BadRequestException(
- `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following types are allowed: [${validFilterOperators}]`
+ let referenceFieldId: string | undefined;
+ if (isFieldReferenceValue(value)) {
+ referenceFieldId = value.fieldId;
+ } else if (Array.isArray(value)) {
+ referenceFieldId = (
+ value.find((entry) => isFieldReferenceValue(entry)) as IFieldReferenceValue | undefined
+ )?.fieldId;
+ }
+
+ if (referenceFieldId) {
+ const referenceName = this.fields?.[referenceFieldId]?.name ?? referenceFieldId;
+ const sourceName = field.name ?? field.id;
+ throw new FieldReferenceCompatibilityException(sourceName, referenceName);
+ }
+
+ throw new CustomHttpException(
+ `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following types are allowed: [${validFilterOperators}]`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.view.filterInvalidOperator',
+ },
+ }
);
}
@@ -105,31 +131,42 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface {
'mode' in value &&
!includes(validFilterSubOperators, value.mode)
) {
- throw new BadRequestException(
- `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following subtypes are allowed: [${validFilterSubOperators}]`
+ throw new CustomHttpException(
+ `The '${convertOperator}' operation provided for the '${field.name}' filter is invalid. Only the following subtypes are allowed: [${validFilterSubOperators}]`,
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.view.filterInvalidOperatorMode',
+ },
+ }
);
}
queryBuilder = queryBuilder[conjunction];
- this.getFilterAdapter(field).compiler(queryBuilder, convertOperator as IFilterOperator, value);
+ this.getFilterAdapter(field).compiler(
+ queryBuilder,
+ convertOperator as IFilterOperator,
+ value,
+ this.dbProvider!
+ );
return queryBuilder;
}
- private getFilterAdapter(field: IFieldInstance): AbstractCellValueFilter {
+ private getFilterAdapter(field: FieldCore): AbstractCellValueFilter {
const { dbFieldType } = field;
switch (field.cellValueType) {
case CellValueType.Boolean:
- return this.booleanFilter(field);
+ return this.booleanFilter(field, this.context);
case CellValueType.Number:
- return this.numberFilter(field);
+ return this.numberFilter(field, this.context);
case CellValueType.DateTime:
- return this.dateTimeFilter(field);
+ return this.dateTimeFilter(field, this.context);
case CellValueType.String: {
if (dbFieldType === DbFieldType.Json) {
- return this.jsonFilter(field);
+ return this.jsonFilter(field, this.context);
}
- return this.stringFilter(field);
+ return this.stringFilter(field, this.context);
}
}
}
@@ -163,12 +200,15 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface {
private replaceMeTagInValue(
filterItem: IFilterItem,
- field: IFieldInstance,
+ field: FieldCore,
replaceUserId?: string
): void {
const { value } = filterItem;
- if (field.type === FieldType.User && replaceUserId) {
+ if (
+ [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type) &&
+ replaceUserId
+ ) {
filterItem.value = Array.isArray(value)
? (value.map((v) => (isMeTag(v as string) ? replaceUserId : v)) as ILiteralValueList)
: isMeTag(value as string)
@@ -177,21 +217,36 @@ export abstract class AbstractFilterQuery implements IFilterQueryInterface {
}
}
- private shouldKeepFilterItem(value: unknown, field: IFieldInstance, operator: string): boolean {
+ private shouldKeepFilterItem(value: unknown, field: FieldCore, operator: string): boolean {
return (
value !== null ||
- field.type === FieldType.Checkbox ||
+ field.cellValueType === CellValueType.Boolean ||
([isEmpty.value, isNotEmpty.value] as string[]).includes(operator)
);
}
- abstract booleanFilter(field: IFieldInstance): AbstractCellValueFilter;
-
- abstract numberFilter(field: IFieldInstance): AbstractCellValueFilter;
-
- abstract dateTimeFilter(field: IFieldInstance): AbstractCellValueFilter;
-
- abstract stringFilter(field: IFieldInstance): AbstractCellValueFilter;
-
- abstract jsonFilter(field: IFieldInstance): AbstractCellValueFilter;
+ abstract booleanFilter(
+ field: FieldCore,
+ context?: IRecordQueryFilterContext
+ ): AbstractCellValueFilter;
+
+ abstract numberFilter(
+ field: FieldCore,
+ context?: IRecordQueryFilterContext
+ ): AbstractCellValueFilter;
+
+ abstract dateTimeFilter(
+ field: FieldCore,
+ context?: IRecordQueryFilterContext
+ ): AbstractCellValueFilter;
+
+ abstract stringFilter(
+ field: FieldCore,
+ context?: IRecordQueryFilterContext
+ ): AbstractCellValueFilter;
+
+ abstract jsonFilter(
+ field: FieldCore,
+ context?: IRecordQueryFilterContext
+ ): AbstractCellValueFilter;
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/cell-value-filter.postgres.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/cell-value-filter.postgres.ts
index dd559f8084..e3516a704c 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/cell-value-filter.postgres.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/cell-value-filter.postgres.ts
@@ -1,39 +1,55 @@
import type { IFilterOperator, IFilterValue } from '@teable/core';
-import { CellValueType, literalValueListSchema } from '@teable/core';
+import {
+ CellValueType,
+ doesNotContain,
+ isFieldReferenceValue,
+ isNoneOf,
+ literalValueListSchema,
+} from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../db.provider.interface';
import { AbstractCellValueFilter } from '../../cell-value-filter.abstract';
export class CellValueFilterPostgres extends AbstractCellValueFilter {
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
const { cellValueType } = this.field;
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ${ref}`);
+ return builderClient;
+ }
const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
-
- builderClient.whereRaw(`?? IS DISTINCT FROM ?`, [this.columnName, parseValue]);
+ builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ?`, [parseValue]);
return builderClient;
}
doesNotContainOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`COALESCE(??, '') NOT LIKE ?`, [this.columnName, `%${value}%`]);
+ this.ensureLiteralValue(value, doesNotContain.value);
+ builderClient.whereRaw(`COALESCE(${this.tableColumnRef}, '') NOT LIKE ?`, [`%${value}%`]);
return builderClient;
}
isNoneOfOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, isNoneOf.value);
const valueList = literalValueListSchema.parse(value);
- const sql = `COALESCE(??, '') NOT IN (${this.createSqlPlaceholders(valueList)})`;
- builderClient.whereRaw(sql, [this.columnName, ...valueList]);
+ const sql = `COALESCE(${this.tableColumnRef}, '') NOT IN (${this.createSqlPlaceholders(valueList)})`;
+ builderClient.whereRaw(sql, valueList);
return builderClient;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts
index 374565a45d..d45a482a29 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts
@@ -1,18 +1,34 @@
import type { IFilterOperator, IFilterValue } from '@teable/core';
+import { isFieldReferenceValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
-import { BooleanCellValueFilterAdapter } from '../single-value/boolean-cell-value-filter.adapter';
export class MultipleBooleanCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return new BooleanCellValueFilterAdapter(this.dbTableName, this.field).isOperatorHandler(
- builderClient,
- operator,
- value
- );
+ if (isFieldReferenceValue(value)) {
+ return super.isOperatorHandler(builderClient, operator, value, dbProvider);
+ }
+
+ const tableColumnRef = this.tableColumnRef;
+
+ if (value) {
+ // Filter for checked/true: match JSONB arrays that contain at least one true value
+ builderClient.whereRaw(`${tableColumnRef} @> '[true]'::jsonb`);
+ } else {
+ // Filter for unchecked/false: match records that do NOT contain any true value
+ // This includes: null, empty arrays, or arrays with only false/null values
+ builderClient.where(function () {
+ this.whereRaw(`${tableColumnRef} is null`);
+ this.orWhereRaw(`NOT (${tableColumnRef} @> '[true]'::jsonb)`);
+ });
+ }
+
+ return builderClient;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts
index 459043d1e2..7988cb0a33 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts
@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-identical-functions */
-import type { IDateFieldOptions, IDateFilter, IFilterOperator } from '@teable/core';
+import type { IDateFieldOptions, IDateFilter, IFilterOperator, IFilterValue } from '@teable/core';
import type { Knex } from 'knex';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
@@ -7,14 +7,17 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterPostg
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
builderClient.whereRaw(
- `??::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}" && @ <= "${dateTimeRange[1]}")'`,
- [this.columnName]
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}" && @ <= "${dateTimeRange[1]}")'`
);
return builderClient;
}
@@ -22,85 +25,108 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterPostg
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
builderClient.whereRaw(
- `NOT ??::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}" && @ <= "${dateTimeRange[1]}")'`,
- [this.columnName]
+ `(NOT ${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}" && @ <= "${dateTimeRange[1]}")' OR ${this.tableColumnRef} IS NULL)`
);
+
return builderClient;
}
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ > "${dateTimeRange[1]}")'`, [
- this.columnName,
- ]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ > "${dateTimeRange[1]}")'`
+ );
return builderClient;
}
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}")'`, [
- this.columnName,
- ]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}")'`
+ );
return builderClient;
}
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ < "${dateTimeRange[0]}")'`, [
- this.columnName,
- ]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ < "${dateTimeRange[0]}")'`
+ );
return builderClient;
}
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ <= "${dateTimeRange[1]}")'`, [
- this.columnName,
- ]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ <= "${dateTimeRange[1]}")'`
+ );
return builderClient;
}
isWithInOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
builderClient.whereRaw(
- `??::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}" && @ <= "${dateTimeRange[1]}")'`,
- [this.columnName]
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ >= "${dateTimeRange[0]}" && @ <= "${dateTimeRange[1]}")'`
);
return builderClient;
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts
index d6e78da52d..87e67550f0 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts
@@ -1,22 +1,55 @@
-import type { IFilterOperator, ILiteralValue, ILiteralValueList } from '@teable/core';
-import { FieldType } from '@teable/core';
+import type {
+ FieldCore,
+ IFieldReferenceValue,
+ IFilterOperator,
+ ILiteralValue,
+ ILiteralValueList,
+} from '@teable/core';
+import { FieldType, isFieldReferenceValue } from '@teable/core';
import type { Knex } from 'knex';
+import { isUserOrLink } from '../../../../../utils/is-user-or-link';
+import { escapeJsonbRegex, escapePostgresRegex } from '../../../../../utils/postgres-regex-escape';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ builderClient.whereRaw(
+ `${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray}`
+ );
+ return builderClient;
+ }
+
const { type } = this.field;
+ const literalValues: ILiteralValueList = Array.isArray(value)
+ ? (value as ILiteralValueList)
+ : ([value] as ILiteralValueList);
+
+ if (isUserOrLink(type)) {
+ return this.isAnyOfOperatorHandler(builderClient, _operator, literalValues, _dbProvider);
+ }
if (type === FieldType.Link) {
- const parseValue = JSON.stringify({ title: value });
+ const parseValue = JSON.stringify({ title: literalValues[0] });
- builderClient.whereRaw(`??::jsonb @> ?::jsonb`, [this.columnName, parseValue]);
+ builderClient.whereRaw(`${this.tableColumnRef}::jsonb @> ?::jsonb`, [parseValue]);
} else {
- builderClient.whereRaw(`??::jsonb \\? ?`, [this.columnName, value]);
+ const escapedValue = escapePostgresRegex(String(literalValues[0]));
+ builderClient.whereRaw(
+ `EXISTS (
+ SELECT 1 FROM jsonb_array_elements_text(${this.tableColumnRef}::jsonb) as elem
+ WHERE elem ~* ?
+ )`,
+ [`^${escapedValue}$`]
+ );
}
return builderClient;
}
@@ -24,19 +57,42 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ builderClient.whereRaw(
+ `NOT (${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray})`
+ );
+ return builderClient;
+ }
+
const { type } = this.field;
+ const literalValues: ILiteralValueList = Array.isArray(value)
+ ? (value as ILiteralValueList)
+ : ([value] as ILiteralValueList);
+
+ if (isUserOrLink(type)) {
+ return this.isNoneOfOperatorHandler(builderClient, _operator, literalValues, _dbProvider);
+ }
if (type === FieldType.Link) {
- const parseValue = JSON.stringify({ title: value });
+ const parseValue = JSON.stringify({ title: literalValues[0] });
- builderClient.whereRaw(`NOT COALESCE(??, '[]')::jsonb @> ?::jsonb`, [
- this.columnName,
+ builderClient.whereRaw(`NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @> ?::jsonb`, [
parseValue,
]);
} else {
- builderClient.whereRaw(`NOT COALESCE(??, '[]')::jsonb \\? ?`, [this.columnName, value]);
+ const escapedValue = escapePostgresRegex(String(literalValues[0]));
+ builderClient.whereRaw(
+ `NOT EXISTS (
+ SELECT 1 FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem
+ WHERE elem ~* ?
+ )`,
+ [`^${escapedValue}$`]
+ );
}
return builderClient;
}
@@ -44,20 +100,30 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres
isExactlyOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ builderClient.whereRaw(
+ `${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray}`
+ );
+ return builderClient;
+ }
+
const { type } = this.field;
const sqlPlaceholders = this.createSqlPlaceholders(value);
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isUserOrLink(type)) {
builderClient.whereRaw(
- `jsonb_path_query_array(??::jsonb, '$[*].id') @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> jsonb_path_query_array(??::jsonb, '$[*].id')`,
- [this.columnName, ...value, ...value, this.columnName]
+ `jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id') @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id')`,
+ [...value, ...value]
);
} else {
builderClient.whereRaw(
- `??::jsonb @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> ??::jsonb`,
- [this.columnName, ...value, ...value, this.columnName]
+ `${this.tableColumnRef}::jsonb @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> ${this.tableColumnRef}::jsonb`,
+ [...value, ...value]
);
}
return builderClient;
@@ -66,21 +132,26 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres
isAnyOfOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
const { type } = this.field;
- const sqlPlaceholders = this.createSqlPlaceholders(value);
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);
+ return builderClient;
+ }
+
+ if (isUserOrLink(type)) {
builderClient.whereRaw(
- `jsonb_path_query_array(??::jsonb, '$[*].id') \\?| ARRAY[${sqlPlaceholders}]`,
- [this.columnName, ...value]
+ `jsonb_exists_any(jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id'), ?::text[])`,
+ [value]
);
} else {
- builderClient.whereRaw(`??::jsonb \\?| ARRAY[${sqlPlaceholders}]`, [
- this.columnName,
- ...value,
- ]);
+ builderClient.whereRaw(`jsonb_exists_any(${this.tableColumnRef}::jsonb, ?::text[])`, [value]);
}
return builderClient;
}
@@ -88,21 +159,31 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres
isNoneOfOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
const { type } = this.field;
- const sqlPlaceholders = this.createSqlPlaceholders(value);
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
builderClient.whereRaw(
- `NOT jsonb_path_query_array(COALESCE(??, '[]')::jsonb, '$[*].id') \\?| ARRAY[${sqlPlaceholders}]`,
- [this.columnName, ...value]
+ `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`
+ );
+ return builderClient;
+ }
+
+ if (isUserOrLink(type)) {
+ builderClient.whereRaw(
+ `NOT jsonb_exists_any(jsonb_path_query_array(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*].id'), ?::text[])`,
+ [value]
);
} else {
- builderClient.whereRaw(`NOT COALESCE(??, '[]')::jsonb \\?| ARRAY[${sqlPlaceholders}]`, [
- this.columnName,
- ...value,
- ]);
+ builderClient.whereRaw(
+ `NOT jsonb_exists_any(COALESCE(${this.tableColumnRef}, '[]')::jsonb, ?::text[])`,
+ [value]
+ );
}
return builderClient;
}
@@ -110,22 +191,59 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres
hasAllOfOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ const { type } = this.field;
+
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ builderClient.whereRaw(`${selfArray} @> ${referenceArray}`);
+ return builderClient;
+ }
+
+ if (isUserOrLink(type)) {
+ builderClient.whereRaw(
+ `jsonb_exists_all(jsonb_path_query_array(${this.tableColumnRef}::jsonb, '$[*].id'), ?::text[])`,
+ [value]
+ );
+ } else {
+ builderClient.whereRaw(`jsonb_exists_all(${this.tableColumnRef}::jsonb, ?::text[])`, [value]);
+ }
+ return builderClient;
+ }
+
+ isNotExactlyOperatorHandler(
+ builderClient: Knex.QueryBuilder,
+ _operator: IFilterOperator,
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
+ ): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ builderClient.whereRaw(
+ `NOT (${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray})`
+ );
+ return builderClient;
+ }
+
const { type } = this.field;
const sqlPlaceholders = this.createSqlPlaceholders(value);
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isUserOrLink(type)) {
builderClient.whereRaw(
- `jsonb_path_query_array(??::jsonb, '$[*].id') @> to_jsonb(ARRAY[${sqlPlaceholders}])`,
- [this.columnName, ...value]
+ `(NOT (jsonb_path_query_array(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*].id') @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> jsonb_path_query_array(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*].id')) OR ${this.tableColumnRef} IS NULL)`,
+ [...value, ...value]
);
} else {
- builderClient.whereRaw(`??::jsonb @> to_jsonb(ARRAY[${sqlPlaceholders}])`, [
- this.columnName,
- ...value,
- ]);
+ builderClient.whereRaw(
+ `(NOT (COALESCE(${this.tableColumnRef}, '[]')::jsonb @> to_jsonb(ARRAY[${sqlPlaceholders}]) AND to_jsonb(ARRAY[${sqlPlaceholders}]) @> COALESCE(${this.tableColumnRef}, '[]')::jsonb) OR ${this.tableColumnRef} IS NULL)`,
+ [...value, ...value]
+ );
}
+
return builderClient;
}
@@ -135,15 +253,16 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres
value: ILiteralValue
): Knex.QueryBuilder {
const { type } = this.field;
+ const escapedValue = escapeJsonbRegex(String(value));
if (type === FieldType.Link) {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@.title like_regex "${value}" flag "i")'`, [
- this.columnName,
- ]);
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @\\? '$[*].title \\? (@ like_regex "${String(escapedValue)}" flag "i")'`
+ );
} else {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ like_regex "${value}" flag "i")'`, [
- this.columnName,
- ]);
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ like_regex "${String(escapedValue)}" flag "i")'`
+ );
}
return builderClient;
}
@@ -154,18 +273,42 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterPostgres
value: ILiteralValue
): Knex.QueryBuilder {
const { type } = this.field;
+ const escapedValue = escapeJsonbRegex(String(value));
if (type === FieldType.Link) {
builderClient.whereRaw(
- `NOT COALESCE(??, '[]')::jsonb @\\? '$[*] \\? (@.title like_regex "${value}" flag "i")'`,
- [this.columnName]
+ `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? '$[*].title \\? (@ like_regex "${String(escapedValue)}" flag "i")'`
);
} else {
builderClient.whereRaw(
- `NOT COALESCE(??, '[]')::jsonb @\\? '$[*] \\? (@ like_regex "${value}" flag "i")'`,
- [this.columnName]
+ `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? '$[*] \\? (@ like_regex "${String(escapedValue)}" flag "i")'`
);
}
return builderClient;
}
+
+ private buildReferenceJsonArray(value: IFieldReferenceValue): string {
+ const referenceExpression = this.resolveFieldReference(value);
+ const referenceField = this.getComparableReferenceField(value);
+ return this.buildJsonArrayExpression(referenceExpression, referenceField);
+ }
+
+ private buildJsonArrayExpression(columnExpression: string, field?: FieldCore): string {
+ const targetField = field ?? this.field;
+ const fallback = targetField.isMultipleCellValue ? "'[]'::jsonb" : "'null'::jsonb";
+ return `jsonb_path_query_array(COALESCE(${columnExpression}, ${fallback}), ${this.getJsonPath(
+ targetField
+ )})`;
+ }
+
+ private buildTextArrayExpression(jsonArrayExpression: string): string {
+ return `COALESCE((SELECT array_agg(value) FROM jsonb_array_elements_text(${jsonArrayExpression}) AS value), ARRAY[]::text[])`;
+ }
+
+ private getJsonPath(field: FieldCore): string {
+ if (isUserOrLink(field.type)) {
+ return field.isMultipleCellValue ? "'$[*].id'" : "'$.id'";
+ }
+ return field.isMultipleCellValue ? "'$[*]'" : "'$'";
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-number-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-number-cell-value-filter.adapter.ts
index a7f45b0be1..02b13feb8c 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-number-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-number-cell-value-filter.adapter.ts
@@ -1,62 +1,306 @@
-import type { IFilterOperator, ILiteralValue } from '@teable/core';
+import type {
+ FieldCore,
+ IFieldReferenceValue,
+ IFilterOperator,
+ ILiteralValue,
+ ILiteralValueList,
+} from '@teable/core';
+import { isFieldReferenceValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class MultipleNumberCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`??::jsonb @> '[?]'::jsonb`, [this.columnName, Number(value)]);
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);
+ return builderClient;
+ }
+
+ builderClient.whereRaw(`${this.tableColumnRef}::jsonb @> jsonb_build_array(?::numeric)`, [
+ Number(value),
+ ]);
return builderClient;
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`NOT COALESCE(??, '[]')::jsonb @> '[?]'::jsonb`, [
- this.columnName,
- Number(value),
- ]);
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(
+ `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`
+ );
+ return builderClient;
+ }
+
+ builderClient.whereRaw(
+ `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @> jsonb_build_array(?::numeric)`,
+ [Number(value)]
+ );
return builderClient;
}
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ > ?)'`, [this.columnName, Number(value)]);
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '>'));
+ return builderClient;
+ }
+
+ builderClient.whereRaw(
+ `
+ EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem
+ WHERE elem::numeric > ?::numeric
+ )
+ `,
+ [Number(value)]
+ );
return builderClient;
}
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ >= ?)'`, [this.columnName, Number(value)]);
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '>='));
+ return builderClient;
+ }
+
+ builderClient.whereRaw(
+ `
+ EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem
+ WHERE elem::numeric >= ?::numeric
+ )
+ `,
+ [Number(value)]
+ );
return builderClient;
}
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ < ?)'`, [this.columnName, Number(value)]);
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '<'));
+ return builderClient;
+ }
+
+ builderClient.whereRaw(
+ `
+ EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem
+ WHERE elem::numeric < ?::numeric
+ )
+ `,
+ [Number(value)]
+ );
return builderClient;
}
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ <= ?)'`, [this.columnName, Number(value)]);
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ builderClient.whereRaw(this.buildComparisonSql(selfArray, referenceArray, '<='));
+ return builderClient;
+ }
+
+ builderClient.whereRaw(
+ `
+ EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements_text(COALESCE(${this.tableColumnRef}, '[]')::jsonb) as elem
+ WHERE elem::numeric <= ?::numeric
+ )
+ `,
+ [Number(value)]
+ );
return builderClient;
}
+
+ isAnyOfOperatorHandler(
+ builderClient: Knex.QueryBuilder,
+ _operator: IFilterOperator,
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
+ ): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);
+ return builderClient;
+ }
+
+ const numericList = (value as ILiteralValueList).map((entry) => Number(entry));
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb \\?| ARRAY[${this.createSqlPlaceholders(numericList)}]`,
+ numericList
+ );
+ return builderClient;
+ }
+
+ isNoneOfOperatorHandler(
+ builderClient: Knex.QueryBuilder,
+ _operator: IFilterOperator,
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
+ ): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(
+ `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`
+ );
+ return builderClient;
+ }
+
+ const numericList = (value as ILiteralValueList).map((entry) => Number(entry));
+ builderClient.whereRaw(
+ `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb \\?| ARRAY[${this.createSqlPlaceholders(numericList)}]`,
+ numericList
+ );
+ return builderClient;
+ }
+
+ hasAllOfOperatorHandler(
+ builderClient: Knex.QueryBuilder,
+ _operator: IFilterOperator,
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
+ ): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(`jsonb_exists_all(${selfArray}, ${referenceTextArray})`);
+ return builderClient;
+ }
+
+ const numericList = (value as ILiteralValueList).map((entry) => Number(entry));
+ builderClient.whereRaw(
+ `jsonb_exists_all(${this.tableColumnRef}::jsonb, ARRAY[${this.createSqlPlaceholders(numericList)}])`,
+ numericList
+ );
+ return builderClient;
+ }
+
+ isExactlyOperatorHandler(
+ builderClient: Knex.QueryBuilder,
+ _operator: IFilterOperator,
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
+ ): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ builderClient.whereRaw(
+ `${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray}`
+ );
+ return builderClient;
+ }
+
+ const numericList = (value as ILiteralValueList).map((entry) => Number(entry));
+ const placeholders = this.createSqlPlaceholders(numericList);
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @> to_jsonb(ARRAY[${placeholders}]) AND to_jsonb(ARRAY[${placeholders}]) @> ${this.tableColumnRef}::jsonb`,
+ [...numericList, ...numericList]
+ );
+ return builderClient;
+ }
+
+ isNotExactlyOperatorHandler(
+ builderClient: Knex.QueryBuilder,
+ _operator: IFilterOperator,
+ value: ILiteralValueList | IFieldReferenceValue,
+ _dbProvider: IDbProvider
+ ): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceArray = this.buildReferenceJsonArray(value);
+ builderClient.whereRaw(
+ `NOT (${selfArray} @> ${referenceArray} AND ${referenceArray} @> ${selfArray})`
+ );
+ return builderClient;
+ }
+
+ const numericList = (value as ILiteralValueList).map((entry) => Number(entry));
+ const placeholders = this.createSqlPlaceholders(numericList);
+ builderClient.whereRaw(
+ `(NOT (${this.tableColumnRef}::jsonb @> to_jsonb(ARRAY[${placeholders}]) AND to_jsonb(ARRAY[${placeholders}]) @> ${this.tableColumnRef}::jsonb) OR ${this.tableColumnRef} IS NULL)`,
+ [...numericList, ...numericList]
+ );
+ return builderClient;
+ }
+
+ private buildJsonArrayExpression(columnExpression: string, field: FieldCore): string {
+ if (field.isMultipleCellValue) {
+ return `COALESCE(${columnExpression}, '[]'::jsonb)`;
+ }
+ return `jsonb_build_array(${columnExpression})`;
+ }
+
+ private buildReferenceJsonArray(value: IFieldReferenceValue): string {
+ const referenceExpression = this.resolveFieldReference(value);
+ const referenceField = this.getComparableReferenceField(value);
+ return this.buildJsonArrayExpression(referenceExpression, referenceField);
+ }
+
+ private buildTextArrayExpression(jsonArrayExpression: string): string {
+ return `COALESCE((SELECT array_agg(value) FROM jsonb_array_elements_text(${jsonArrayExpression}) AS value), ARRAY[]::text[])`;
+ }
+
+ private buildComparisonSql(
+ selfArray: string,
+ referenceArray: string,
+ operator: '>' | '>=' | '<' | '<='
+ ): string {
+ return `EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements_text(${selfArray}) AS self_elem(value)
+ CROSS JOIN jsonb_array_elements_text(${referenceArray}) AS ref_elem(value)
+ WHERE (self_elem.value)::numeric ${operator} (ref_elem.value)::numeric
+ )`;
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts
index 13cb54e28a..56d58aeab2 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts
@@ -1,47 +1,57 @@
import type { IFilterOperator, ILiteralValue } from '@teable/core';
import type { Knex } from 'knex';
+import { escapeJsonbRegex } from '../../../../../utils/postgres-regex-escape';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class MultipleStringCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ == "${value}")'`, [this.columnName]);
+ this.ensureLiteralValue(value, _operator);
+ builderClient.whereRaw(`${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ == "${value}")'`);
return builderClient;
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`NOT COALESCE(??, '[]')::jsonb @\\? '$[*] \\? (@ == "${value}")'`, [
- this.columnName,
- ]);
+ builderClient.whereRaw(
+ `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? '$[*] \\? (@ == "${value}")'`
+ );
return builderClient;
}
containsOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ like_regex "${value}" flag "i")'`, [
- this.columnName,
- ]);
+ const escapedValue = escapeJsonbRegex(String(value));
+ this.ensureLiteralValue(value, _operator);
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ like_regex "${escapedValue}" flag "i")'`
+ );
return builderClient;
}
doesNotContainOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ const escapedValue = escapeJsonbRegex(String(value));
+ this.ensureLiteralValue(value, _operator);
builderClient.whereRaw(
- `NOT COALESCE(??, '[]')::jsonb @\\? '$[*] \\? (@ like_regex "${value}" flag "i")'`,
- [this.columnName]
+ `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? '$[*] \\? (@ like_regex "${escapedValue}" flag "i")'`
);
return builderClient;
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts
index 159a6d2914..ad9bde5f54 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts
@@ -1,17 +1,33 @@
-import type { IFilterOperator, IFilterValue } from '@teable/core';
+import { isFieldReferenceValue, type IFilterOperator, type IFilterValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class BooleanCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return (value ? super.isNotEmptyOperatorHandler : super.isEmptyOperatorHandler).bind(this)(
- builderClient,
- operator,
- value
- );
+ if (isFieldReferenceValue(value)) {
+ return super.isOperatorHandler(builderClient, operator, value, dbProvider);
+ }
+
+ const tableColumnRef = this.tableColumnRef;
+
+ if (value) {
+ // Filter for checked/true: match exactly true values
+ builderClient.whereRaw(`${tableColumnRef} = true`);
+ } else {
+ // Filter for unchecked/false: match false values OR null values
+ // This handles both formula fields (which return false) and checkbox fields (which store null)
+ builderClient.where(function () {
+ this.whereRaw(`${tableColumnRef} = false`);
+ this.orWhereRaw(`${tableColumnRef} is null`);
+ });
+ }
+
+ return builderClient;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts
index 121c73cf9e..85f8535138 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts
@@ -1,90 +1,246 @@
/* eslint-disable sonarjs/no-identical-functions */
-import type { IDateFieldOptions, IDateFilter, IFilterOperator } from '@teable/core';
+import {
+ DateFormattingPreset,
+ isFieldReferenceValue,
+ type IDateFieldOptions,
+ type IDateFilter,
+ type IDatetimeFormatting,
+ type IFilterOperator,
+ type IFilterValue,
+} from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class DatetimeCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ return this.applyFieldReferenceEquality(builderClient, ref, 'is');
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereBetween(this.columnName, dateTimeRange);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(
+ `${this.tableColumnRef} BETWEEN ?::timestamptz AND ?::timestamptz`,
+ dateTimeRange
+ );
return builderClient;
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ return this.applyFieldReferenceEquality(builderClient, ref, 'isNot');
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereNotBetween(this.columnName, dateTimeRange);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+
+ // Wrap conditions in a nested `.whereRaw()` to ensure proper SQL grouping with parentheses,
+ // generating `WHERE ("data" NOT BETWEEN ... OR "data" IS NULL) AND other_query`.
+ builderClient.whereRaw(
+ `(${this.tableColumnRef} NOT BETWEEN ?::timestamptz AND ?::timestamptz OR ${this.tableColumnRef} IS NULL)`,
+ dateTimeRange
+ );
return builderClient;
}
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ return this.applyFieldReferenceComparison(builderClient, ref, 'gt');
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '>', dateTimeRange[1]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} > ?::timestamptz`, [dateTimeRange[1]]);
return builderClient;
}
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ return this.applyFieldReferenceComparison(builderClient, ref, 'gte');
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '>=', dateTimeRange[0]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} >= ?::timestamptz`, [dateTimeRange[0]]);
return builderClient;
}
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ return this.applyFieldReferenceComparison(builderClient, ref, 'lt');
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '<', dateTimeRange[0]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} < ?::timestamptz`, [dateTimeRange[0]]);
return builderClient;
}
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ return this.applyFieldReferenceComparison(builderClient, ref, 'lte');
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '<=', dateTimeRange[1]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} <= ?::timestamptz`, [dateTimeRange[1]]);
return builderClient;
}
isWithInOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereBetween(this.columnName, dateTimeRange);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(
+ `${this.tableColumnRef} BETWEEN ?::timestamptz AND ?::timestamptz`,
+ dateTimeRange
+ );
+ return builderClient;
+ }
+
+ private extractFormatting(): IDatetimeFormatting | undefined {
+ const options = this.field.options as { formatting?: IDatetimeFormatting } | undefined;
+ return options?.formatting;
+ }
+
+ private determineDateUnit(formatting?: IDatetimeFormatting): 'day' | 'month' | 'year' {
+ const dateFormat = formatting?.date as DateFormattingPreset | undefined;
+ switch (dateFormat) {
+ case DateFormattingPreset.Y:
+ return 'year';
+ case DateFormattingPreset.YM:
+ case DateFormattingPreset.M:
+ return 'month';
+ default:
+ return 'day';
+ }
+ }
+
+ private wrapWithTimeZone(expr: string, formatting?: IDatetimeFormatting): string {
+ const tz = (formatting?.timeZone || 'UTC').replace(/'/g, "''");
+ return `(${expr}) AT TIME ZONE '${tz}'`;
+ }
+
+ private applyFieldReferenceEquality(
+ builderClient: Knex.QueryBuilder,
+ referenceExpression: string,
+ mode: 'is' | 'isNot'
+ ): Knex.QueryBuilder {
+ const formatting = this.extractFormatting();
+ const unit = this.determineDateUnit(formatting);
+
+ const left = this.buildTruncatedExpression(this.tableColumnRef, unit, formatting);
+ const right = this.buildTruncatedExpression(referenceExpression, unit, formatting);
+
+ if (mode === 'is') {
+ builderClient.whereRaw(`${left} = ${right}`);
+ } else {
+ builderClient.whereRaw(`${left} IS DISTINCT FROM ${right}`);
+ }
+
+ return builderClient;
+ }
+
+ private applyFieldReferenceComparison(
+ builderClient: Knex.QueryBuilder,
+ referenceExpression: string,
+ comparator: 'gt' | 'gte' | 'lt' | 'lte'
+ ): Knex.QueryBuilder {
+ const formatting = this.extractFormatting();
+ const unit = this.determineDateUnit(formatting);
+
+ const left = this.buildTruncatedExpression(this.tableColumnRef, unit, formatting);
+ const right = this.buildTruncatedExpression(referenceExpression, unit, formatting);
+
+ const comparatorMap = {
+ gt: '>',
+ gte: '>=',
+ lt: '<',
+ lte: '<=',
+ } as const;
+
+ builderClient.whereRaw(`${left} ${comparatorMap[comparator]} ${right}`);
return builderClient;
}
+
+ private buildTruncatedExpression(
+ expression: string,
+ unit: 'day' | 'month' | 'year',
+ formatting?: IDatetimeFormatting
+ ): string {
+ return `DATE_TRUNC('${unit}', ${this.wrapWithTimeZone(expression, formatting)})`;
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/json-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/json-cell-value-filter.adapter.ts
index 02a93de858..1a4d90d164 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/json-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/json-cell-value-filter.adapter.ts
@@ -1,20 +1,59 @@
-import type { IFilterOperator, IFilterValue, ILiteralValue, ILiteralValueList } from '@teable/core';
-import { FieldType } from '@teable/core';
+/* eslint-disable sonarjs/no-duplicate-string */
+import type {
+ FieldCore,
+ IFieldReferenceValue,
+ IFilterOperator,
+ IFilterValue,
+ ILiteralValue,
+ ILiteralValueList,
+} from '@teable/core';
+import { FieldType, isFieldReferenceValue } from '@teable/core';
import type { Knex } from 'knex';
+import { isUserOrLink } from '../../../../../utils/is-user-or-link';
+import { escapeJsonbRegex } from '../../../../../utils/postgres-regex-escape';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class JsonCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
- builderClient.whereRaw(`??::jsonb @\\? '$.id \\? (@ == "${value}")'`, [this.columnName]);
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+
+ if (isUserOrLink(type)) {
+ const referenceField = this.getComparableReferenceField(value);
+ if (referenceField.isMultipleCellValue) {
+ const leftIdExpr = `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id')`;
+ const refArrayExpr = `jsonb_path_query_array(COALESCE(${ref}, '[]'::jsonb), '$[*].id')`;
+ builderClient.whereRaw(
+ `EXISTS (SELECT 1 FROM jsonb_array_elements_text(${refArrayExpr}) AS ref_id WHERE ref_id = ${leftIdExpr})`
+ );
+ return builderClient;
+ }
+ builderClient.whereRaw(
+ `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') = jsonb_extract_path_text(${ref}::jsonb, 'id')`
+ );
+ return builderClient;
+ }
+
+ return super.isOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
+ if (isUserOrLink(type)) {
+ builderClient.whereRaw(`jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') = ?`, [
+ value,
+ ]);
} else {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ == "${value}")'`, [this.columnName]);
+ builderClient.whereRaw(
+ `jsonb_path_exists(${this.tableColumnRef}::jsonb, ?::jsonpath, jsonb_build_object('value', to_jsonb(?::text)))`,
+ ['$[*] ? (@ like_regex $value flag "i")', value]
+ );
}
return builderClient;
}
@@ -22,18 +61,43 @@ export class JsonCellValueFilterAdapter extends CellValueFilterPostgres {
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
- builderClient.whereRaw(`NOT COALESCE(??, '{}')::jsonb @\\? '$.id \\? (@ == "${value}")'`, [
- this.columnName,
- ]);
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+
+ if (isUserOrLink(type)) {
+ const referenceField = this.getComparableReferenceField(value);
+ if (referenceField.isMultipleCellValue) {
+ const leftIdExpr = `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id')`;
+ const refArrayExpr = `jsonb_path_query_array(COALESCE(${ref}, '[]'::jsonb), '$[*].id')`;
+ builderClient.whereRaw(
+ `NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(${refArrayExpr}) AS ref_id WHERE ref_id = ${leftIdExpr})`
+ );
+ return builderClient;
+ }
+ builderClient.whereRaw(
+ `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') IS DISTINCT FROM jsonb_extract_path_text(${ref}::jsonb, 'id')`
+ );
+ return builderClient;
+ }
+
+ return super.isNotOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
+ if (isUserOrLink(type)) {
+ builderClient.whereRaw(
+ `jsonb_extract_path_text(COALESCE(${this.tableColumnRef}, '{}'::jsonb), 'id') IS DISTINCT FROM ?`,
+ [value]
+ );
} else {
- builderClient.whereRaw(`NOT COALESCE(??, '[]')::jsonb @\\? '$[*] \\? (@ == "${value}")'`, [
- this.columnName,
- ]);
+ builderClient.whereRaw(
+ `NOT jsonb_path_exists(COALESCE(${this.tableColumnRef}, '[]')::jsonb, ?::jsonpath, jsonb_build_object('value', to_jsonb(?::text)))`,
+ ['$[*] ? (@ like_regex $value flag "i")', value]
+ );
}
return builderClient;
}
@@ -41,20 +105,28 @@ export class JsonCellValueFilterAdapter extends CellValueFilterPostgres {
isAnyOfOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue
): Knex.QueryBuilder {
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(`jsonb_exists_any(${selfArray}, ${referenceTextArray})`);
+ return builderClient;
+ }
+
+ if (isUserOrLink(type)) {
builderClient.whereRaw(
- `jsonb_extract_path_text(??::jsonb, 'id') IN (${this.createSqlPlaceholders(value)})`,
- [this.columnName, ...value]
+ `jsonb_extract_path_text(${this.tableColumnRef}::jsonb, 'id') IN (${this.createSqlPlaceholders(value)})`,
+ value
);
} else {
- builderClient.whereRaw(`??::jsonb \\?| ARRAY[${this.createSqlPlaceholders(value)}]`, [
- this.columnName,
- ...value,
- ]);
+ builderClient.whereRaw(
+ `${this.tableColumnRef}::jsonb \\?| ARRAY[${this.createSqlPlaceholders(value)}]`,
+ value
+ );
}
return builderClient;
}
@@ -62,21 +134,31 @@ export class JsonCellValueFilterAdapter extends CellValueFilterPostgres {
isNoneOfOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: ILiteralValueList
+ value: ILiteralValueList | IFieldReferenceValue
): Knex.QueryBuilder {
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isFieldReferenceValue(value)) {
+ const referenceArray = this.buildReferenceJsonArray(value);
+ const selfArray = this.buildJsonArrayExpression(this.tableColumnRef, this.field);
+ const referenceTextArray = this.buildTextArrayExpression(referenceArray);
+ builderClient.whereRaw(
+ `NOT jsonb_exists_any(COALESCE(${selfArray}, '[]'::jsonb), ${referenceTextArray})`
+ );
+ return builderClient;
+ }
+
+ if (isUserOrLink(type)) {
builderClient.whereRaw(
- `COALESCE(jsonb_extract_path_text(COALESCE(??, '{}')::jsonb, 'id'), '') NOT IN (${this.createSqlPlaceholders(
+ `COALESCE(jsonb_extract_path_text(COALESCE(${this.tableColumnRef}, '{}')::jsonb, 'id'), '') NOT IN (${this.createSqlPlaceholders(
value
)})`,
- [this.columnName, ...value]
+ value
);
} else {
builderClient.whereRaw(
- `NOT COALESCE(??, '[]')::jsonb \\?| ARRAY[${this.createSqlPlaceholders(value)}]`,
- [this.columnName, ...value]
+ `NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb \\?| ARRAY[${this.createSqlPlaceholders(value)}]`,
+ value
);
}
return builderClient;
@@ -88,15 +170,16 @@ export class JsonCellValueFilterAdapter extends CellValueFilterPostgres {
value: IFilterValue
): Knex.QueryBuilder {
const { type } = this.field;
+ const escapedValue = escapeJsonbRegex(String(value));
if (type === FieldType.Link) {
- builderClient.whereRaw(`??::jsonb @\\? '$.title \\? (@ like_regex "${value}" flag "i")'`, [
- this.columnName,
- ]);
+ builderClient.whereRaw(
+ `jsonb_path_exists(${this.tableColumnRef}::jsonb, '$.title \\? (@ like_regex "${escapedValue}" flag "i")'::jsonpath)`
+ );
} else {
- builderClient.whereRaw(`??::jsonb @\\? '$[*] \\? (@ like_regex "${value}" flag "i")'`, [
- this.columnName,
- ]);
+ builderClient.whereRaw(
+ `jsonb_path_exists(${this.tableColumnRef}::jsonb, '$[*] \\? (@ like_regex "${escapedValue}" flag "i")'::jsonpath)`
+ );
}
return builderClient;
}
@@ -107,18 +190,42 @@ export class JsonCellValueFilterAdapter extends CellValueFilterPostgres {
value: IFilterValue
): Knex.QueryBuilder {
const { type } = this.field;
+ const escapedValue = escapeJsonbRegex(String(value));
if (type === FieldType.Link) {
builderClient.whereRaw(
- `NOT COALESCE(??, '{}')::jsonb @\\? '$.title \\? (@ like_regex "${value}" flag "i")'`,
- [this.columnName]
+ `NOT jsonb_path_exists(COALESCE(${this.tableColumnRef}, '{}')::jsonb, '$.title \\? (@ like_regex "${escapedValue}" flag "i")'::jsonpath)`
);
} else {
builderClient.whereRaw(
- `NOT COALESCE(??, '[]')::jsonb @\\? '$[*] \\? (@ like_regex "${value}" flag "i")'`,
- [this.columnName]
+ `NOT jsonb_path_exists(COALESCE(${this.tableColumnRef}, '[]')::jsonb, '$[*] \\? (@ like_regex "${escapedValue}" flag "i")'::jsonpath)`
);
}
return builderClient;
}
+
+ private buildReferenceJsonArray(value: IFieldReferenceValue): string {
+ const referenceExpression = this.resolveFieldReference(value);
+ const referenceField = this.getComparableReferenceField(value);
+ return this.buildJsonArrayExpression(referenceExpression, referenceField);
+ }
+
+ private buildJsonArrayExpression(columnExpression: string, field?: FieldCore): string {
+ const targetField = field ?? this.field;
+ const fallback = targetField.isMultipleCellValue ? "'[]'::jsonb" : "'null'::jsonb";
+ return `jsonb_path_query_array(COALESCE(${columnExpression}, ${fallback}), ${this.getJsonPath(
+ targetField
+ )})`;
+ }
+
+ private buildTextArrayExpression(jsonArrayExpression: string): string {
+ return `COALESCE((SELECT array_agg(value) FROM jsonb_array_elements_text(${jsonArrayExpression}) AS value), ARRAY[]::text[])`;
+ }
+
+ private getJsonPath(field: FieldCore): string {
+ if (isUserOrLink(field.type)) {
+ return field.isMultipleCellValue ? "'$[*].id'" : "'$.id'";
+ }
+ return field.isMultipleCellValue ? "'$[*]'" : "'$'";
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/number-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/number-cell-value-filter.adapter.ts
index bb67a24e99..8113f0cf0e 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/number-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/number-cell-value-filter.adapter.ts
@@ -1,53 +1,60 @@
import type { IFilterOperator, ILiteralValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class NumberCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isOperatorHandler(builderClient, operator, value);
+ return super.isOperatorHandler(builderClient, operator, value, dbProvider);
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isNotOperatorHandler(builderClient, operator, value);
+ return super.isNotOperatorHandler(builderClient, operator, value, dbProvider);
}
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isGreaterOperatorHandler(builderClient, operator, value);
+ return super.isGreaterOperatorHandler(builderClient, operator, value, dbProvider);
}
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isGreaterEqualOperatorHandler(builderClient, operator, value);
+ return super.isGreaterEqualOperatorHandler(builderClient, operator, value, dbProvider);
}
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isLessOperatorHandler(builderClient, operator, value);
+ return super.isLessOperatorHandler(builderClient, operator, value, dbProvider);
}
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isLessEqualOperatorHandler(builderClient, operator, value);
+ return super.isLessEqualOperatorHandler(builderClient, operator, value, dbProvider);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts
index 04553dc4ed..be892185d5 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/cell-value-filter/single-value/string-cell-value-filter.adapter.ts
@@ -1,37 +1,73 @@
-import type { IFilterOperator, ILiteralValue } from '@teable/core';
+import {
+ CellValueType,
+ isFieldReferenceValue,
+ type IFieldReferenceValue,
+ type IFilterOperator,
+ type ILiteralValue,
+} from '@teable/core';
import type { Knex } from 'knex';
+import { escapeLikeWildcards } from '../../../../../utils/sql-like-escape';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';
export class StringCellValueFilterAdapter extends CellValueFilterPostgres {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: ILiteralValue
+ _operator: IFilterOperator,
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isOperatorHandler(builderClient, operator, value);
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} = ${ref}`);
+ return builderClient;
+ }
+ const parseValue = this.field.cellValueType === CellValueType.Number ? Number(value) : value;
+ builderClient.whereRaw(`${this.tableColumnRef} = ?`, [parseValue]);
+ return builderClient;
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: ILiteralValue
+ _operator: IFilterOperator,
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isNotOperatorHandler(builderClient, operator, value);
+ const { cellValueType } = this.field;
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ${ref}`);
+ return builderClient;
+ }
+ const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
+ builderClient.whereRaw(`${this.tableColumnRef} IS DISTINCT FROM ?`, [parseValue]);
+ return builderClient;
}
containsOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: ILiteralValue
+ _operator: IFilterOperator,
+ value: ILiteralValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.containsOperatorHandler(builderClient, operator, value);
+ this.ensureLiteralValue(value, _operator);
+ const escapedValue = escapeLikeWildcards(String(value));
+ builderClient.whereRaw(`${this.tableColumnRef} iLIKE ? ESCAPE '\\'`, [`%${escapedValue}%`]);
+ return builderClient;
}
doesNotContainOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: ILiteralValue
+ _operator: IFilterOperator,
+ value: ILiteralValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.doesNotContainOperatorHandler(builderClient, operator, value);
+ this.ensureLiteralValue(value, _operator);
+ const escapedValue = escapeLikeWildcards(String(value));
+ builderClient.whereRaw(
+ `LOWER(COALESCE(${this.tableColumnRef}, '')) NOT LIKE LOWER(?) ESCAPE '\\'`,
+ [`%${escapedValue}%`]
+ );
+ return builderClient;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/postgres/filter-query.postgres.ts b/apps/nestjs-backend/src/db-provider/filter-query/postgres/filter-query.postgres.ts
index 8774953714..7ccef8f47f 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/postgres/filter-query.postgres.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/postgres/filter-query.postgres.ts
@@ -1,4 +1,7 @@
-import type { IFieldInstance } from '../../../features/field/model/factory';
+import type { FieldCore, IFilter } from '@teable/core';
+import type { Knex } from 'knex';
+import type { IRecordQueryFilterContext } from '../../../features/record/query-builder/record-query-builder.interface';
+import type { IDbProvider, IFilterQueryExtra } from '../../db.provider.interface';
import { AbstractFilterQuery } from '../filter-query.abstract';
import {
BooleanCellValueFilterAdapter,
@@ -15,43 +18,53 @@ import {
import type { CellValueFilterPostgres } from './cell-value-filter/cell-value-filter.postgres';
export class FilterQueryPostgres extends AbstractFilterQuery {
- booleanFilter(field: IFieldInstance): CellValueFilterPostgres {
+ constructor(
+ originQueryBuilder: Knex.QueryBuilder,
+ fields?: { [fieldId: string]: FieldCore },
+ filter?: IFilter,
+ extra?: IFilterQueryExtra,
+ dbProvider?: IDbProvider,
+ context?: IRecordQueryFilterContext
+ ) {
+ super(originQueryBuilder, fields, filter, extra, dbProvider, context);
+ }
+ booleanFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleBooleanCellValueFilterAdapter(this._table, field);
+ return new MultipleBooleanCellValueFilterAdapter(field, context);
}
- return new BooleanCellValueFilterAdapter(this._table, field);
+ return new BooleanCellValueFilterAdapter(field, context);
}
- numberFilter(field: IFieldInstance): CellValueFilterPostgres {
+ numberFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleNumberCellValueFilterAdapter(this._table, field);
+ return new MultipleNumberCellValueFilterAdapter(field, context);
}
- return new NumberCellValueFilterAdapter(this._table, field);
+ return new NumberCellValueFilterAdapter(field, context);
}
- dateTimeFilter(field: IFieldInstance): CellValueFilterPostgres {
+ dateTimeFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleDatetimeCellValueFilterAdapter(this._table, field);
+ return new MultipleDatetimeCellValueFilterAdapter(field, context);
}
- return new DatetimeCellValueFilterAdapter(this._table, field);
+ return new DatetimeCellValueFilterAdapter(field, context);
}
- stringFilter(field: IFieldInstance): CellValueFilterPostgres {
+ stringFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleStringCellValueFilterAdapter(this._table, field);
+ return new MultipleStringCellValueFilterAdapter(field, context);
}
- return new StringCellValueFilterAdapter(this._table, field);
+ return new StringCellValueFilterAdapter(field, context);
}
- jsonFilter(field: IFieldInstance): CellValueFilterPostgres {
+ jsonFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleJsonCellValueFilterAdapter(this._table, field);
+ return new MultipleJsonCellValueFilterAdapter(field, context);
}
- return new JsonCellValueFilterAdapter(this._table, field);
+ return new JsonCellValueFilterAdapter(field, context);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/cell-value-filter.sqlite.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/cell-value-filter.sqlite.ts
index fe5b4325f9..9daea8a333 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/cell-value-filter.sqlite.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/cell-value-filter.sqlite.ts
@@ -1,50 +1,66 @@
-import type { IFilterOperator, IFilterValue } from '@teable/core';
+import type { FieldCore, IFilterOperator, IFilterValue } from '@teable/core';
import {
CellValueType,
contains,
doesNotContain,
FieldType,
+ isFieldReferenceValue,
+ isNoneOf,
literalValueListSchema,
} from '@teable/core';
import type { Knex } from 'knex';
-import type { IFieldInstance } from '../../../../features/field/model/factory';
+import { escapeLikeWildcards } from '../../../../utils/sql-like-escape';
+import type { IDbProvider } from '../../../db.provider.interface';
import { AbstractCellValueFilter } from '../../cell-value-filter.abstract';
export class CellValueFilterSqlite extends AbstractCellValueFilter {
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
const { cellValueType } = this.field;
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`ifnull(${this.tableColumnRef}, '') != ${ref}`);
+ return builderClient;
+ }
const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
- builderClient.whereRaw(`ifnull(${this.columnName}, '') != ?`, [parseValue]);
+ builderClient.whereRaw(`ifnull(${this.tableColumnRef}, '') != ?`, [parseValue]);
return builderClient;
}
doesNotContainOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- builderClient.whereRaw(`ifnull(${this.columnName}, '') not like ?`, [`%${value}%`]);
+ this.ensureLiteralValue(value, doesNotContain.value);
+ const escapedValue = escapeLikeWildcards(String(value));
+ builderClient.whereRaw(`ifnull(${this.tableColumnRef}, '') not like ? ESCAPE '\\'`, [
+ `%${escapedValue}%`,
+ ]);
return builderClient;
}
isNoneOfOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, isNoneOf.value);
const valueList = literalValueListSchema.parse(value);
- const sql = `ifnull(${this.columnName}, '') not in (${this.createSqlPlaceholders(valueList)})`;
+ const sql = `ifnull(${this.tableColumnRef}, '') not in (${this.createSqlPlaceholders(valueList)})`;
builderClient.whereRaw(sql, [...valueList]);
return builderClient;
}
- protected getJsonQueryColumn(field: IFieldInstance, operator: IFilterOperator): string {
+ protected getJsonQueryColumn(field: FieldCore, operator: IFilterOperator): string {
const defaultJsonColumn = 'json_each.value';
if (field.type === FieldType.Link) {
const object = field.isMultipleCellValue ? defaultJsonColumn : field.dbFieldName;
@@ -54,7 +70,7 @@ export class CellValueFilterSqlite extends AbstractCellValueFilter {
return `json_extract(${object}, '${path}')`;
}
- if (field.type === FieldType.User) {
+ if ([FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy].includes(field.type)) {
const object = field.isMultipleCellValue ? defaultJsonColumn : field.dbFieldName;
const path = '$.id';
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts
index aaf4028380..73d061a23f 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-boolean-cell-value-filter.adapter.ts
@@ -1,18 +1,39 @@
import type { IFilterOperator, IFilterValue } from '@teable/core';
+import { isFieldReferenceValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterSqlite } from '../cell-value-filter.sqlite';
-import { BooleanCellValueFilterAdapter } from '../single-value/boolean-cell-value-filter.adapter';
export class MultipleBooleanCellValueFilterAdapter extends CellValueFilterSqlite {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return new BooleanCellValueFilterAdapter(this.dbTableName, this.field).isOperatorHandler(
- builderClient,
- operator,
- value
- );
+ if (isFieldReferenceValue(value)) {
+ return super.isOperatorHandler(builderClient, operator, value, dbProvider);
+ }
+
+ const tableColumnRef = this.tableColumnRef;
+
+ if (value) {
+ // Filter for checked/true: match JSON arrays that contain at least one true value (stored as 1)
+ // Use json_each to check if any element equals 1 (true in SQLite)
+ builderClient.whereRaw(
+ `EXISTS (SELECT 1 FROM json_each(${tableColumnRef}) WHERE json_each.value = 1)`
+ );
+ } else {
+ // Filter for unchecked/false: match records that do NOT contain any true value
+ // This includes: null, empty arrays, or arrays with only false/null values
+ builderClient.where(function () {
+ this.whereRaw(`${tableColumnRef} is null`);
+ this.orWhereRaw(
+ `NOT EXISTS (SELECT 1 FROM json_each(${tableColumnRef}) WHERE json_each.value = 1)`
+ );
+ });
+ }
+
+ return builderClient;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts
index 540b9b068a..18a1078d23 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-datetime-cell-value-filter.adapter.ts
@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-identical-functions */
-import type { IDateFieldOptions, IDateFilter, IFilterOperator } from '@teable/core';
+import type { IDateFieldOptions, IDateFilter, IFilterOperator, IFilterValue } from '@teable/core';
import type { Knex } from 'knex';
import { CellValueFilterSqlite } from '../cell-value-filter.sqlite';
@@ -7,11 +7,15 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlit
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -24,11 +28,15 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlit
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
const sql = `not exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -41,11 +49,15 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlit
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -58,11 +70,15 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlit
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -75,11 +91,15 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlit
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -92,11 +112,15 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlit
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -109,11 +133,15 @@ export class MultipleDatetimeCellValueFilterAdapter extends CellValueFilterSqlit
isWithInOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts
index 7a9498b9e9..d8b8c53b63 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-json-cell-value-filter.adapter.ts
@@ -10,8 +10,8 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterSqlite {
value: ILiteralValueList
): Knex.QueryBuilder {
const jsonColumn = this.getJsonQueryColumn(this.field, operator);
- const isOfSql = `exists (select 1 from json_each(??) where ?? = ?)`;
- builderClient.whereRaw(isOfSql, [this.tableColumnRef, jsonColumn, value]);
+ const isOfSql = `exists (select 1 from json_each(${this.tableColumnRef}) where lower(${jsonColumn}) = lower(?))`;
+ builderClient.whereRaw(isOfSql, [value]);
return builderClient;
}
@@ -21,8 +21,8 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterSqlite {
value: ILiteralValueList
): Knex.QueryBuilder {
const jsonColumn = this.getJsonQueryColumn(this.field, operator);
- const isNotOfSql = `not exists (select 1 from json_each(??) where ?? = ?)`;
- builderClient.whereRaw(isNotOfSql, [this.tableColumnRef, jsonColumn, value]);
+ const isNotOfSql = `not exists (select 1 from json_each(${this.tableColumnRef}) where lower(${jsonColumn}) = lower(?))`;
+ builderClient.whereRaw(isNotOfSql, [value]);
return builderClient;
}
@@ -33,13 +33,19 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterSqlite {
): Knex.QueryBuilder {
const jsonColumn = this.getJsonQueryColumn(this.field, operator);
const isExactlySql = `(
- select count(distinct json_each.value) from
+ select count(${jsonColumn}) from
json_each(${this.tableColumnRef})
where ${jsonColumn} in (${this.createSqlPlaceholders(value)})
- and json_array_length(${this.tableColumnRef}) = ?
+ ) >= ?`;
+
+ const isFullMatchSql = `(
+ select count(distinct ${jsonColumn}) from
+ json_each(${this.tableColumnRef})
) = ?`;
- const vLength = value.length;
- builderClient.whereRaw(isExactlySql, [...value, vLength, vLength]);
+
+ builderClient
+ .whereRaw(isExactlySql, [...value, value.length])
+ .whereRaw(isFullMatchSql, [value.length]);
return builderClient;
}
@@ -88,6 +94,25 @@ export class MultipleJsonCellValueFilterAdapter extends CellValueFilterSqlite {
return builderClient;
}
+ isNotExactlyOperatorHandler(
+ builderClient: Knex.QueryBuilder,
+ operator: IFilterOperator,
+ value: ILiteralValueList
+ ): Knex.QueryBuilder {
+ const jsonColumn = this.getJsonQueryColumn(this.field, operator);
+ const isNotExactlySql = `NOT ((
+ select count(${jsonColumn}) from
+ json_each(${this.tableColumnRef})
+ where ${jsonColumn} in (${this.createSqlPlaceholders(value)})
+ ) >= ? AND (
+ select count(distinct ${jsonColumn}) from
+ json_each(${this.tableColumnRef})
+ ) = ?)`;
+
+ builderClient.whereRaw(isNotExactlySql, [...value, value.length, value.length]);
+ return builderClient;
+ }
+
containsOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts
index 6e536dc934..e01f62e3d1 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/multiple-value/multiple-string-cell-value-filter.adapter.ts
@@ -8,6 +8,7 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterSqlite
_operator: IFilterOperator,
value: ILiteralValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -22,6 +23,7 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterSqlite
_operator: IFilterOperator,
value: ILiteralValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const sql = `not exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -36,6 +38,7 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterSqlite
_operator: IFilterOperator,
value: ILiteralValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const sql = `exists (
select 1 from
json_each(${this.tableColumnRef})
@@ -50,6 +53,7 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterSqlite
_operator: IFilterOperator,
value: ILiteralValue
): Knex.QueryBuilder {
+ this.ensureLiteralValue(value, _operator);
const sql = `not exists (
select 1 from
json_each(${this.tableColumnRef})
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts
index 6f1d9ff529..98177dbc9b 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/boolean-cell-value-filter.adapter.ts
@@ -1,17 +1,33 @@
-import type { IFilterOperator, IFilterValue } from '@teable/core';
+import { isFieldReferenceValue, type IFilterOperator, type IFilterValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterSqlite } from '../cell-value-filter.sqlite';
export class BooleanCellValueFilterAdapter extends CellValueFilterSqlite {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: IFilterValue
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return (value ? super.isNotEmptyOperatorHandler : super.isEmptyOperatorHandler).bind(this)(
- builderClient,
- operator,
- value
- );
+ if (isFieldReferenceValue(value)) {
+ return super.isOperatorHandler(builderClient, operator, value, dbProvider);
+ }
+
+ const tableColumnRef = this.tableColumnRef;
+
+ if (value) {
+ // Filter for checked/true: match exactly true values (stored as 1 in SQLite)
+ builderClient.whereRaw(`${tableColumnRef} = 1`);
+ } else {
+ // Filter for unchecked/false: match false values OR null values
+ // This handles both formula fields (which return false/0) and checkbox fields (which store null)
+ builderClient.where(function () {
+ this.whereRaw(`${tableColumnRef} = 0`);
+ this.orWhereRaw(`${tableColumnRef} is null`);
+ });
+ }
+
+ return builderClient;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts
index 619f6681ed..4721fd8140 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/datetime-cell-value-filter.adapter.ts
@@ -1,90 +1,156 @@
/* eslint-disable sonarjs/no-identical-functions */
-import type { IDateFieldOptions, IDateFilter, IFilterOperator } from '@teable/core';
+import {
+ isFieldReferenceValue,
+ type IDateFieldOptions,
+ type IDateFilter,
+ type IFilterOperator,
+ type IFilterValue,
+} from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterSqlite } from '../cell-value-filter.sqlite';
export class DatetimeCellValueFilterAdapter extends CellValueFilterSqlite {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereBetween(this.columnName, dateTimeRange);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} BETWEEN ? AND ?`, dateTimeRange);
return builderClient;
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isNotOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereNotBetween(this.columnName, dateTimeRange);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(
+ `(${this.tableColumnRef} NOT BETWEEN ? AND ? OR ${this.tableColumnRef} IS NULL)`,
+ dateTimeRange
+ );
return builderClient;
}
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isGreaterOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '>', dateTimeRange[1]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} > ?`, [dateTimeRange[1]]);
return builderClient;
}
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isGreaterEqualOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '>=', dateTimeRange[0]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} >= ?`, [dateTimeRange[0]]);
return builderClient;
}
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isLessOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '<', dateTimeRange[0]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} < ?`, [dateTimeRange[0]]);
return builderClient;
}
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isLessEqualOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.where(this.columnName, '<=', dateTimeRange[1]);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} <= ?`, [dateTimeRange[1]]);
return builderClient;
}
isWithInOperatorHandler(
builderClient: Knex.QueryBuilder,
_operator: IFilterOperator,
- value: IDateFilter
+ value: IFilterValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
+ if (isFieldReferenceValue(value)) {
+ return super.isOperatorHandler(builderClient, _operator, value, dbProvider);
+ }
+
const { options } = this.field;
- const dateTimeRange = this.getFilterDateTimeRange(options as IDateFieldOptions, value);
- builderClient.whereBetween(this.columnName, dateTimeRange);
+ const dateTimeRange = this.getFilterDateTimeRange(
+ options as IDateFieldOptions,
+ value as IDateFilter
+ );
+ builderClient.whereRaw(`${this.tableColumnRef} BETWEEN ? AND ?`, dateTimeRange);
return builderClient;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/json-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/json-cell-value-filter.adapter.ts
index e57f6903fd..9a5f6d336f 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/json-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/json-cell-value-filter.adapter.ts
@@ -1,15 +1,49 @@
-import type { IFilterOperator, IFilterValue, ILiteralValue, ILiteralValueList } from '@teable/core';
+import type {
+ IFieldReferenceValue,
+ IFilterOperator,
+ IFilterValue,
+ ILiteralValue,
+ ILiteralValueList,
+} from '@teable/core';
+import { FieldType, isFieldReferenceValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterSqlite } from '../cell-value-filter.sqlite';
export class JsonCellValueFilterAdapter extends CellValueFilterSqlite {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
const jsonColumn = this.getJsonQueryColumn(this.field, operator);
- const sql = `${jsonColumn} = ?`;
+
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+
+ if (
+ [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.Link].includes(
+ this.field.type
+ )
+ ) {
+ const referenceField = this.getComparableReferenceField(value);
+ if (referenceField.isMultipleCellValue) {
+ const refColumn = "json_extract(json_each.value, '$.id')";
+ builderClient.whereRaw(
+ `exists (select 1 from json_each(${ref}) where lower(${refColumn}) = lower(${jsonColumn}))`
+ );
+ return builderClient;
+ }
+ const refColumn = `json_extract(${ref}, '$.id')`;
+ builderClient.whereRaw(`lower(${jsonColumn}) = lower(${refColumn})`);
+ return builderClient;
+ }
+
+ return super.isOperatorHandler(builderClient, operator, value, dbProvider);
+ }
+
+ const sql = `lower(${jsonColumn}) = lower(?)`;
builderClient.whereRaw(sql, [value]);
return builderClient;
}
@@ -17,10 +51,36 @@ export class JsonCellValueFilterAdapter extends CellValueFilterSqlite {
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue | IFieldReferenceValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
const jsonColumn = this.getJsonQueryColumn(this.field, operator);
- const sql = `ifnull(${jsonColumn}, '') != ?`;
+
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+
+ if (
+ [FieldType.User, FieldType.CreatedBy, FieldType.LastModifiedBy, FieldType.Link].includes(
+ this.field.type
+ )
+ ) {
+ const referenceField = this.getComparableReferenceField(value);
+ if (referenceField.isMultipleCellValue) {
+ const refColumn = "json_extract(json_each.value, '$.id')";
+ builderClient.whereRaw(
+ `not exists (select 1 from json_each(${ref}) where lower(${refColumn}) = lower(${jsonColumn}))`
+ );
+ return builderClient;
+ }
+ const refColumn = `json_extract(${ref}, '$.id')`;
+ builderClient.whereRaw(`lower(ifnull(${jsonColumn}, '')) != lower(${refColumn})`);
+ return builderClient;
+ }
+
+ return super.isNotOperatorHandler(builderClient, operator, value, dbProvider);
+ }
+
+ const sql = `lower(ifnull(${jsonColumn}, '')) != lower(?)`;
builderClient.whereRaw(sql, [value]);
return builderClient;
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/number-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/number-cell-value-filter.adapter.ts
index bc9b4e6c3f..a6ebc74be0 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/number-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/number-cell-value-filter.adapter.ts
@@ -1,53 +1,60 @@
import type { IFilterOperator, ILiteralValue } from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterSqlite } from '../cell-value-filter.sqlite';
export class NumberCellValueFilterAdapter extends CellValueFilterSqlite {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isOperatorHandler(builderClient, operator, value);
+ return super.isOperatorHandler(builderClient, operator, value, dbProvider);
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isNotOperatorHandler(builderClient, operator, value);
+ return super.isNotOperatorHandler(builderClient, operator, value, dbProvider);
}
isGreaterOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isGreaterOperatorHandler(builderClient, operator, value);
+ return super.isGreaterOperatorHandler(builderClient, operator, value, dbProvider);
}
isGreaterEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isGreaterEqualOperatorHandler(builderClient, operator, value);
+ return super.isGreaterEqualOperatorHandler(builderClient, operator, value, dbProvider);
}
isLessOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isLessOperatorHandler(builderClient, operator, value);
+ return super.isLessOperatorHandler(builderClient, operator, value, dbProvider);
}
isLessEqualOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isLessEqualOperatorHandler(builderClient, operator, value);
+ return super.isLessEqualOperatorHandler(builderClient, operator, value, dbProvider);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/string-cell-value-filter.adapter.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/string-cell-value-filter.adapter.ts
index a019df8aaa..6b2bae6941 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/string-cell-value-filter.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/cell-value-filter/single-value/string-cell-value-filter.adapter.ts
@@ -1,37 +1,65 @@
-import type { IFilterOperator, ILiteralValue } from '@teable/core';
+import {
+ CellValueType,
+ isFieldReferenceValue,
+ type IFieldReferenceValue,
+ type IFilterOperator,
+ type ILiteralValue,
+} from '@teable/core';
import type { Knex } from 'knex';
+import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterSqlite } from '../cell-value-filter.sqlite';
export class StringCellValueFilterAdapter extends CellValueFilterSqlite {
isOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: ILiteralValue
+ _operator: IFilterOperator,
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isOperatorHandler(builderClient, operator, value);
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} = ${ref}`);
+ return builderClient;
+ }
+ const parseValue = this.field.cellValueType === CellValueType.Number ? Number(value) : value;
+ builderClient.whereRaw(`${this.tableColumnRef} = ?`, [parseValue]);
+ return builderClient;
}
isNotOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: ILiteralValue
+ _operator: IFilterOperator,
+ value: ILiteralValue | IFieldReferenceValue,
+ _dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.isNotOperatorHandler(builderClient, operator, value);
+ const { cellValueType } = this.field;
+ if (isFieldReferenceValue(value)) {
+ const ref = this.resolveFieldReference(value);
+ builderClient.whereRaw(`${this.tableColumnRef} != ${ref}`);
+ return builderClient;
+ }
+ const parseValue = cellValueType === CellValueType.Number ? Number(value) : value;
+ builderClient.whereRaw(`${this.tableColumnRef} != ?`, [parseValue]);
+ return builderClient;
}
containsOperatorHandler(
builderClient: Knex.QueryBuilder,
- operator: IFilterOperator,
- value: ILiteralValue
+ _operator: IFilterOperator,
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.containsOperatorHandler(builderClient, operator, value);
+ this.ensureLiteralValue(value, _operator);
+ return super.containsOperatorHandler(builderClient, _operator, value, dbProvider);
}
doesNotContainOperatorHandler(
builderClient: Knex.QueryBuilder,
operator: IFilterOperator,
- value: ILiteralValue
+ value: ILiteralValue,
+ dbProvider: IDbProvider
): Knex.QueryBuilder {
- return super.doesNotContainOperatorHandler(builderClient, operator, value);
+ this.ensureLiteralValue(value, operator);
+ return super.doesNotContainOperatorHandler(builderClient, operator, value, dbProvider);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/filter-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/filter-query.sqlite.ts
index 8a42dc3c28..d2f8015b57 100644
--- a/apps/nestjs-backend/src/db-provider/filter-query/sqlite/filter-query.sqlite.ts
+++ b/apps/nestjs-backend/src/db-provider/filter-query/sqlite/filter-query.sqlite.ts
@@ -1,4 +1,7 @@
-import type { IFieldInstance } from '../../../features/field/model/factory';
+import type { FieldCore, IFilter } from '@teable/core';
+import type { Knex } from 'knex';
+import type { IRecordQueryFilterContext } from '../../../features/record/query-builder/record-query-builder.interface';
+import type { IDbProvider, IFilterQueryExtra } from '../../db.provider.interface';
import type { AbstractCellValueFilter } from '../cell-value-filter.abstract';
import { AbstractFilterQuery } from '../filter-query.abstract';
import {
@@ -16,43 +19,53 @@ import {
import type { CellValueFilterSqlite } from './cell-value-filter/cell-value-filter.sqlite';
export class FilterQuerySqlite extends AbstractFilterQuery {
- booleanFilter(field: IFieldInstance): CellValueFilterSqlite {
+ constructor(
+ originQueryBuilder: Knex.QueryBuilder,
+ fields?: { [fieldId: string]: FieldCore },
+ filter?: IFilter,
+ extra?: IFilterQueryExtra,
+ dbProvider?: IDbProvider,
+ context?: IRecordQueryFilterContext
+ ) {
+ super(originQueryBuilder, fields, filter, extra, dbProvider, context);
+ }
+ booleanFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleBooleanCellValueFilterAdapter(this._table, field);
+ return new MultipleBooleanCellValueFilterAdapter(field, context);
}
- return new BooleanCellValueFilterAdapter(this._table, field);
+ return new BooleanCellValueFilterAdapter(field, context);
}
- numberFilter(field: IFieldInstance): CellValueFilterSqlite {
+ numberFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleNumberCellValueFilterAdapter(this._table, field);
+ return new MultipleNumberCellValueFilterAdapter(field, context);
}
- return new NumberCellValueFilterAdapter(this._table, field);
+ return new NumberCellValueFilterAdapter(field, context);
}
- dateTimeFilter(field: IFieldInstance): CellValueFilterSqlite {
+ dateTimeFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleDatetimeCellValueFilterAdapter(this._table, field);
+ return new MultipleDatetimeCellValueFilterAdapter(field, context);
}
- return new DatetimeCellValueFilterAdapter(this._table, field);
+ return new DatetimeCellValueFilterAdapter(field, context);
}
- stringFilter(field: IFieldInstance): CellValueFilterSqlite {
+ stringFilter(field: FieldCore, context?: IRecordQueryFilterContext): CellValueFilterSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleStringCellValueFilterAdapter(this._table, field);
+ return new MultipleStringCellValueFilterAdapter(field, context);
}
- return new StringCellValueFilterAdapter(this._table, field);
+ return new StringCellValueFilterAdapter(field, context);
}
- jsonFilter(field: IFieldInstance): AbstractCellValueFilter {
+ jsonFilter(field: FieldCore, context?: IRecordQueryFilterContext): AbstractCellValueFilter {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleJsonCellValueFilterAdapter(this._table, field);
+ return new MultipleJsonCellValueFilterAdapter(field, context);
}
- return new JsonCellValueFilterAdapter(this._table, field);
+ return new JsonCellValueFilterAdapter(field, context);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts b/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts
new file mode 100644
index 0000000000..04c070c5c5
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/formula-default-unit.spec.ts
@@ -0,0 +1,54 @@
+import { TableDomain } from '@teable/core';
+import knex from 'knex';
+import { describe, expect, it } from 'vitest';
+import type { IFieldSelectName } from '../features/record/query-builder/field-select.type';
+import type { ISelectFormulaConversionContext } from '../features/record/query-builder/sql-conversion.visitor';
+import { PostgresProvider } from './postgres.provider';
+import { SqliteProvider } from './sqlite.provider';
+
+const emptyTable = new TableDomain({
+ id: 'tblFormulaUnit',
+ name: 'Formula Unit',
+ dbTableName: 'public.tbl_formula_unit',
+ lastModifiedTime: '2026-04-08T00:00:00.000Z',
+ fields: [],
+});
+
+const toSql = (result: IFieldSelectName) => {
+ return typeof result === 'string' ? result : result.toQuery();
+};
+
+const context: ISelectFormulaConversionContext = {
+ table: emptyTable,
+ selectionMap: new Map(),
+ tableAlias: 'main',
+ timeZone: 'UTC',
+};
+
+describe('convertFormulaToSelectQuery DATETIME_DIFF defaults', () => {
+ it('defaults DATETIME_DIFF to seconds for postgres select queries', () => {
+ const provider = new PostgresProvider(knex({ client: 'pg' }));
+ const sql = toSql(
+ provider.convertFormulaToSelectQuery(
+ `DATETIME_DIFF(DATETIME_PARSE("2024-01-03T00:00:00.000Z"), DATETIME_PARSE("2024-01-01T00:00:00.000Z"))`,
+ context
+ )
+ );
+
+ expect(sql).toContain('EXTRACT(EPOCH');
+ expect(sql).not.toContain('/ 86400');
+ });
+
+ it('defaults DATETIME_DIFF to seconds for sqlite select queries', () => {
+ const provider = new SqliteProvider(knex({ client: 'sqlite3' }));
+ const sql = toSql(
+ provider.convertFormulaToSelectQuery(
+ `DATETIME_DIFF(DATETIME_PARSE("2024-01-03T00:00:00.000Z"), DATETIME_PARSE("2024-01-01T00:00:00.000Z"))`,
+ context
+ )
+ );
+
+ expect(sql).toContain('* 24.0 * 60 * 60');
+ expect(sql).not.toContain('/ 86400');
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/formula-query.spec.ts.snap b/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/formula-query.spec.ts.snap
new file mode 100644
index 0000000000..3f467d2bbf
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/formula-query.spec.ts.snap
@@ -0,0 +1,359 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayCompact function 1`] = `"ARRAY(SELECT x FROM UNNEST(column_a) AS x WHERE x IS NOT NULL)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayFlatten function 1`] = `"ARRAY(SELECT UNNEST(column_a))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 1`] = `"ARRAY_TO_STRING(column_a, ', ')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 2`] = `"ARRAY_TO_STRING(column_a, ' | ')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayUnique function 1`] = `"ARRAY(SELECT DISTINCT UNNEST(column_a))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement count function 1`] = `"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_c IS NOT NULL THEN 1 ELSE 0 END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countA function 1`] = `"(CASE WHEN column_a IS NULL OR COALESCE(NULLIF((column_a)::text, ''), '') = '' THEN 0 ELSE 1 END + CASE WHEN column_b IS NULL OR COALESCE(NULLIF((column_b)::text, ''), '') = '' THEN 0 ELSE 1 END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countAll function 1`] = `"CASE WHEN column_a IS NULL THEN 0 ELSE 1 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 1`] = `"CASE WHEN (SUM(a, b) > 100) THEN ROUND((a / b)::numeric, 2::integer) ELSE (UPPER(c) || ' - ' || LOWER(d)) END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 2`] = `"CASE WHEN ((a + b) > 100) THEN ROUND((a / b), 2) ELSE (COALESCE(UPPER(c), '') || COALESCE(' - ', '') || COALESCE(LOWER(d), '')) END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle deeply nested expressions 1`] = `"(((((base)))))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 1`] = `"SUM()"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 2`] = `"SUM(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 3`] = `"'test''quote'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 4`] = `"'test"double'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 5`] = `"0"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 6`] = `"-3.14"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 1`] = `"NULL"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 2`] = `"column_a"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 3`] = `"'test''quote'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 4`] = `"'test"double'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 5`] = `"0"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 6`] = `"-3.14"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 1`] = `""column_a""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 2`] = `"\`column_a\`"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement createdTime function 1`] = `"__created_time__"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement dateAdd function with parameters 1`] = `"column_a::timestamp + INTERVAL 'days' * 5::integer"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datestr function with parameters 1`] = `"column_a::date::text"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeDiff function with parameters 1`] = `"EXTRACT(DAY FROM column_b::timestamp - column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeFormat function with parameters 1`] = `"TO_CHAR(column_a::timestamp, 'YYYY-MM-DD')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `"(CASE WHEN (column_a) IS NULL THEN NULL WHEN (column_a)::text = '' THEN NULL WHEN (column_a)::text ~ '^\\d{4}\\-\\d{2}\\-\\d{2}$' THEN TO_TIMESTAMP((column_a)::text, 'YYYY-MM-DD') ELSE NULL END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement day function 1`] = `"EXTRACT(DAY FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement hour function 1`] = `"EXTRACT(HOUR FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 1`] = `"column_a::timestamp = column_b::timestamp"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 2`] = `"DATE_TRUNC('day', column_a::timestamp) = DATE_TRUNC('day', column_b::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 3`] = `"DATE_TRUNC('month', column_a::timestamp) = DATE_TRUNC('month', column_b::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 4`] = `"DATE_TRUNC('year', column_a::timestamp) = DATE_TRUNC('year', column_b::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement lastModifiedTime function 1`] = `"__last_modified_time__"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement minute function 1`] = `"EXTRACT(MINUTE FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement month function 1`] = `"EXTRACT(MONTH FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement now function 1`] = `"NOW()"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement second function 1`] = `"EXTRACT(SECOND FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement today function 1`] = `"CURRENT_DATE"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekNum function 1`] = `"EXTRACT(WEEK FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekday function 1`] = `"EXTRACT(DOW FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workday function with parameters 1`] = `"column_a::date + INTERVAL '1 day' * 5::integer"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workdayDiff function with parameters 1`] = `"column_b::date - column_a::date"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement year function 1`] = `"EXTRACT(YEAR FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should handle field references 1`] = `""column_a""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should set and use context 1`] = `""test_column""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 1`] = `"TRUE"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 2`] = `"FALSE"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement nullLiteral 1`] = `"NULL"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 1`] = `"42"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 2`] = `"-3.14"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 1`] = `"'hello'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 2`] = `"'it''s'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 1`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 2`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 1`] = `"((condition1) AND NOT (condition2)) OR (NOT (condition1) AND (condition2))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 2`] = `"(condition1 + condition2 + condition3) % 2 = 1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement and function 1`] = `"(condition1 AND condition2 AND condition3)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement blank function 1`] = `"NULL"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement if function 1`] = `"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement isError function 1`] = `"CASE WHEN column_a IS NULL THEN TRUE ELSE FALSE END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement not function 1`] = `"NOT (condition)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement or function 1`] = `"(condition1 OR condition2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement abs function 1`] = `"ABS(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement average function 1`] = `"AVG(column_a, column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement ceiling function 1`] = `"CEIL(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement even function 1`] = `"CASE WHEN column_a::integer % 2 = 0 THEN column_a::integer ELSE column_a::integer + 1 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement exp function 1`] = `"EXP(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement floor function 1`] = `"FLOOR(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement int function 1`] = `"FLOOR(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement log function 1`] = `"LN(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement max function 1`] = `"GREATEST(column_a, column_b, 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement min function 1`] = `"LEAST(column_a, column_b, 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement mod function with parameters 1`] = `"MOD(column_a::numeric, 3::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement odd function 1`] = `"CASE WHEN column_a::integer % 2 = 1 THEN column_a::integer ELSE column_a::integer + 1 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement power function with parameters 1`] = `"POWER(column_a::numeric, 2::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 1`] = `"ROUND(column_a::numeric, 2::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 2`] = `"ROUND(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 1`] = `"FLOOR(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 2`] = `"FLOOR(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 1`] = `"CEIL(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 2`] = `"CEIL(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sqrt function 1`] = `"SQRT(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sum function 1`] = `"SUM(column_a, column_b, 10)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement value function 1`] = `"column_a::numeric"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 1`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 2`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement abs function for SQLite 1`] = `"ABS(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement average function for SQLite 1`] = `"((column_a + column_b) / 2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 1`] = `"1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 2`] = `"0"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToBoolean function for SQLite 1`] = `"CAST(column_a AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToDate function for SQLite 1`] = `"DATETIME(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToNumber function for SQLite 1`] = `"CAST(column_a AS REAL)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToString function for SQLite 1`] = `"CAST(column_a AS TEXT)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement ceiling function for SQLite 1`] = `"CAST(CEIL(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement concatenate function for SQLite 1`] = `"(COALESCE(column_a, '') || COALESCE(' - ', '') || COALESCE(column_b, ''))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement count function for SQLite 1`] = `"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement day function for SQLite 1`] = `"CAST(STRFTIME('%d', column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement exp function for SQLite 1`] = `"EXP(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement fieldReference function for SQLite 1`] = `"\`column_a\`"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 1`] = `"INSTR(column_a, 'text')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 2`] = `"CASE WHEN INSTR(SUBSTR(column_a, 5), 'text') > 0 THEN INSTR(SUBSTR(column_a, 5), 'text') + 5 - 1 ELSE 0 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement floor function for SQLite 1`] = `"CAST(FLOOR(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement if function for SQLite 1`] = `"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement isError function for SQLite 1`] = `"CASE WHEN column_a IS NULL THEN 1 ELSE 0 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement left function for SQLite 1`] = `"SUBSTR(column_a, 1, 5)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement len function for SQLite 1`] = `"LENGTH(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement log function for SQLite 1`] = `"LN(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement lower function for SQLite 1`] = `"LOWER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement max function for SQLite 1`] = `"MAX(MAX(column_a, column_b), 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mid function for SQLite 1`] = `"SUBSTR(column_a, 2, 5)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement min function for SQLite 1`] = `"MIN(MIN(column_a, column_b), 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mod function for SQLite 1`] = `"(column_a % 3)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement month function for SQLite 1`] = `"CAST(STRFTIME('%m', column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement now function for SQLite 1`] = `"DATETIME('now')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement power function for SQLite 1`] = `"POWER(column_a, 2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement right function for SQLite 1`] = `"SUBSTR(column_a, -3)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 1`] = `"ROUND(column_a, 2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 2`] = `"ROUND(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 1`] = `"CAST(FLOOR(column_a * POWER(10, 2)) / POWER(10, 2) AS REAL)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 2`] = `"CAST(FLOOR(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 1`] = `"CAST(CEIL(column_a * POWER(10, 2)) / POWER(10, 2) AS REAL)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 2`] = `"CAST(CEIL(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 1`] = `"INSTR(UPPER(column_a), UPPER('text'))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 2`] = `"CASE WHEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) > 0 THEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) + 3 - 1 ELSE 0 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sqrt function for SQLite 1`] = `"SQRT(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement substitute function for SQLite 1`] = `"REPLACE(column_a, 'old', 'new')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sum function for SQLite 1`] = `"(column_a + column_b + 10)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement today function for SQLite 1`] = `"DATE('now')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement trim function for SQLite 1`] = `"TRIM(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement upper function for SQLite 1`] = `"UPPER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement year function for SQLite 1`] = `"CAST(STRFTIME('%Y', column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement autoNumber function 1`] = `"__auto_number"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement recordId function 1`] = `"__id"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement textAll function 1`] = `"ARRAY_TO_STRING(column_a, ', ')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement concatenate function 1`] = `"(column_a || ' - ' || column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement encodeUrlComponent function 1`] = `"encode(column_a::bytea, 'escape')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 1`] = `"POSITION('text' IN column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 2`] = `"POSITION('text' IN SUBSTRING(column_a FROM 5::integer)) + 5::integer - 1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement left function 1`] = `"LEFT(column_a, 5::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement len function 1`] = `"LENGTH(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement lower function 1`] = `"LOWER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement mid function 1`] = `"SUBSTRING(column_a FROM 2::integer FOR 5::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement regexpReplace function 1`] = `"REGEXP_REPLACE((column_a)::text, ('pattern')::text, ('replacement')::text, 'g')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement replace function 1`] = `"OVERLAY(column_a PLACING 'new' FROM 2::integer FOR 3::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement rept function 1`] = `"REPEAT(column_a, 3::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement right function 1`] = `"RIGHT(column_a, 3::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 1`] = `"POSITION(UPPER('text') IN UPPER(column_a))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 2`] = `"POSITION(UPPER('text') IN UPPER(SUBSTRING(column_a FROM 3::integer))) + 3::integer - 1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 1`] = `"REPLACE(column_a, 'old', 'new')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 2`] = `"REPLACE(column_a, 'old', 'new')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement t function 1`] = `"CASE WHEN column_a IS NULL THEN '' ELSE column_a::text END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement trim function 1`] = `"TRIM(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement upper function 1`] = `"UPPER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement add operation 1`] = `"(column_a + column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement bitwiseAnd operation 1`] = `"(column_a & column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToBoolean operation 1`] = `"column_a::boolean"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToDate operation 1`] = `"column_a::timestamp"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToNumber operation 1`] = `"column_a::numeric"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToString operation 1`] = `"column_a::text"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement divide operation 1`] = `"(column_a / column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement equal operation 1`] = `"(column_a = column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThan operation 1`] = `"(column_a > 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThanOrEqual operation 1`] = `"(column_a >= 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThan operation 1`] = `"(column_a < 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThanOrEqual operation 1`] = `"(column_a <= 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalAnd operation 1`] = `"(condition1 AND condition2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalOr operation 1`] = `"(condition1 OR condition2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement modulo operation 1`] = `"(column_a % column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement multiply operation 1`] = `"(column_a * column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement notEqual operation 1`] = `"(column_a <> column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement parentheses operation 1`] = `"(expression)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement subtract operation 1`] = `"(column_a - column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement unaryMinus operation 1`] = `"(-column_a)"`;
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/generated-column-query.spec.ts.snap b/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/generated-column-query.spec.ts.snap
new file mode 100644
index 0000000000..3415395e80
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/generated-column-query.spec.ts.snap
@@ -0,0 +1,434 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayCompact function 1`] = `"ARRAY(SELECT x FROM UNNEST(column_a) AS x WHERE x IS NOT NULL)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayFlatten function 1`] = `"ARRAY(SELECT UNNEST(column_a))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 1`] = `"ARRAY_TO_STRING(column_a, ', ')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayJoin function with optional separator 2`] = `"ARRAY_TO_STRING(column_a, ' | ')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement arrayUnique function 1`] = `"ARRAY(SELECT DISTINCT UNNEST(column_a))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement count function 1`] = `"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_c IS NOT NULL THEN 1 ELSE 0 END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countA function 1`] = `"(CASE WHEN column_a IS NULL OR COALESCE(NULLIF((column_a)::text, ''), '') = '' THEN 0 ELSE 1 END + CASE WHEN column_b IS NULL OR COALESCE(NULLIF((column_b)::text, ''), '') = '' THEN 0 ELSE 1 END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Array Functions > should implement countAll function 1`] = `"CASE WHEN column_a IS NULL THEN 0 ELSE 1 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 1`] = `"CASE WHEN ((a + b) > 100) THEN ROUND((a / b)::numeric, 2::integer) ELSE (COALESCE(UPPER(c)::text, '') || COALESCE(' - '::text, '') || COALESCE(LOWER(d)::text, '')) END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle complex nested function calls 2`] = `"CASE WHEN ((a + b) > 100) THEN ROUND((a / b), 2) ELSE (COALESCE(UPPER(c), '') || COALESCE(' - ', '') || COALESCE(LOWER(d), '')) END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle deeply nested expressions 1`] = `"(((((base)))))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 1`] = `"()"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 2`] = `"(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 3`] = `"'test''quote'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 4`] = `"'test"double'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 5`] = `"0"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for PostgreSQL 6`] = `"-3.14"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 1`] = `"NULL"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 2`] = `"column_a"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 3`] = `"'test''quote'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 4`] = `"'test"double'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 5`] = `"0"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle edge cases for SQLite 6`] = `"-3.14"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 1`] = `""column_a""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Common Interface and Edge Cases > should handle field references differently 2`] = `"\`column_a\`"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement createdTime function 1`] = `""__created_time""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement dateAdd function with parameters 1`] = `"column_a::timestamp + INTERVAL 'days' * 5::integer"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datestr function with parameters 1`] = `"column_a::date::text"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeDiff function with parameters 1`] = `"EXTRACT(DAY FROM column_b::timestamp - column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeFormat function with parameters 1`] = `"TO_CHAR(column_a::timestamp, 'YYYY-MM-DD')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement datetimeParse function with parameters 1`] = `"(CASE WHEN (column_a) IS NULL THEN NULL WHEN (column_a)::text = '' THEN NULL WHEN (column_a)::text ~ '^\\d{4}\\-\\d{2}\\-\\d{2}$' THEN TO_TIMESTAMP((column_a)::text, 'YYYY-MM-DD') ELSE NULL END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement day function 1`] = `"EXTRACT(DAY FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement hour function 1`] = `"EXTRACT(HOUR FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 1`] = `"column_a::timestamp = column_b::timestamp"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 2`] = `"DATE_TRUNC('day', column_a::timestamp) = DATE_TRUNC('day', column_b::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 3`] = `"DATE_TRUNC('month', column_a::timestamp) = DATE_TRUNC('month', column_b::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement isSame function with different units 4`] = `"DATE_TRUNC('year', column_a::timestamp) = DATE_TRUNC('year', column_b::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement lastModifiedTime function 1`] = `""__last_modified_time""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement minute function 1`] = `"EXTRACT(MINUTE FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement month function 1`] = `"EXTRACT(MONTH FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement now function 1`] = `"NOW()"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement second function 1`] = `"EXTRACT(SECOND FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement today function 1`] = `"CURRENT_DATE"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekNum function 1`] = `"EXTRACT(WEEK FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement weekday function 1`] = `"EXTRACT(DOW FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workday function with parameters 1`] = `"column_a::date + INTERVAL '1 day' * 5::integer"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement workdayDiff function with parameters 1`] = `"column_b::date - column_a::date"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Date Functions > should implement year function 1`] = `"EXTRACT(YEAR FROM column_a::timestamp)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should handle field references 1`] = `""column_a""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Field References and Context > should set and use context 1`] = `""test_column""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 1`] = `"TRUE"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement booleanLiteral 2`] = `"FALSE"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement nullLiteral 1`] = `"NULL"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 1`] = `"42"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement numberLiteral 2`] = `"-3.14"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 1`] = `"'hello'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Literal Values > should implement stringLiteral 2`] = `"'it''s'"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 1`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement SWITCH function 2`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 1`] = `"((condition1) AND NOT (condition2)) OR (NOT (condition1) AND (condition2))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement XOR function with different parameter counts 2`] = `"(condition1 + condition2 + condition3) % 2 = 1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement and function 1`] = `"(condition1 AND condition2 AND condition3)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement blank function 1`] = `"NULL"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement if function 1`] = `"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement isError function 1`] = `"CASE WHEN column_a IS NULL THEN TRUE ELSE FALSE END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement not function 1`] = `"NOT (condition)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Logical Functions > should implement or function 1`] = `"(condition1 OR condition2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement abs function 1`] = `"ABS(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement average function 1`] = `"(column_a + column_b) / 2"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement ceiling function 1`] = `"CEIL(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement even function 1`] = `"CASE WHEN column_a::integer % 2 = 0 THEN column_a::integer ELSE column_a::integer + 1 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement exp function 1`] = `"EXP(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement floor function 1`] = `"FLOOR(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement int function 1`] = `"FLOOR(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement log function 1`] = `"LN(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement max function 1`] = `"GREATEST(column_a, column_b, 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement min function 1`] = `"LEAST(column_a, column_b, 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement mod function with parameters 1`] = `"MOD(column_a::numeric, 3::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement odd function 1`] = `"CASE WHEN column_a::integer % 2 = 1 THEN column_a::integer ELSE column_a::integer + 1 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement power function with parameters 1`] = `"POWER(column_a::numeric, 2::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 1`] = `"ROUND(column_a::numeric, 2::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement round function with parameters 2`] = `"ROUND(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 1`] = `"FLOOR(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundDown function with parameters 2`] = `"FLOOR(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 1`] = `"CEIL(column_a::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement roundUp function with parameters 2`] = `"CEIL(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sqrt function 1`] = `"SQRT(column_a::numeric)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement sum function 1`] = `"(column_a + column_b + 10)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Numeric Functions > should implement value function 1`] = `"column_a::numeric"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 1`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement SWITCH function for SQLite 2`] = `"CASE WHEN column_a = 1 THEN 'One' WHEN column_a = 2 THEN 'Two' ELSE 'Default' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement abs function for SQLite 1`] = `"ABS(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement average function for SQLite 1`] = `"((column_a + column_b) / 2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 1`] = `"1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement boolean literals correctly for SQLite 2`] = `"0"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToBoolean function for SQLite 1`] = `"CAST(column_a AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToDate function for SQLite 1`] = `"DATETIME(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToNumber function for SQLite 1`] = `"CAST(column_a AS REAL)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement castToString function for SQLite 1`] = `"CAST(column_a AS TEXT)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement ceiling function for SQLite 1`] = `"CAST(CEIL(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement concatenate function for SQLite 1`] = `"(COALESCE(column_a, '') || COALESCE(' - ', '') || COALESCE(column_b, ''))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement count function for SQLite 1`] = `"(CASE WHEN column_a IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN column_b IS NOT NULL THEN 1 ELSE 0 END)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement day function for SQLite 1`] = `"CAST(STRFTIME('%d', column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement exp function for SQLite 1`] = `"EXP(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement fieldReference function for SQLite 1`] = `"\`column_a\`"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 1`] = `"INSTR(column_a, 'text')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement find function for SQLite 2`] = `"CASE WHEN INSTR(SUBSTR(column_a, 5), 'text') > 0 THEN INSTR(SUBSTR(column_a, 5), 'text') + 5 - 1 ELSE 0 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement floor function for SQLite 1`] = `"CAST(FLOOR(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement if function for SQLite 1`] = `"CASE WHEN column_a > 0 THEN column_b ELSE 'N/A' END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement isError function for SQLite 1`] = `"CASE WHEN column_a IS NULL THEN 1 ELSE 0 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement left function for SQLite 1`] = `"SUBSTR(column_a, 1, 5)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement len function for SQLite 1`] = `"LENGTH(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement log function for SQLite 1`] = `"LN(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement lower function for SQLite 1`] = `"LOWER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement max function for SQLite 1`] = `"MAX(MAX(column_a, column_b), 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mid function for SQLite 1`] = `"SUBSTR(column_a, 2, 5)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement min function for SQLite 1`] = `"MIN(MIN(column_a, column_b), 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement mod function for SQLite 1`] = `"(column_a % 3)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement month function for SQLite 1`] = `"CAST(STRFTIME('%m', column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement now function for SQLite 1`] = `"DATETIME('now')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement power function for SQLite 1`] = `
+"(
+ CASE
+ WHEN 2 = 0 THEN 1
+ WHEN 2 = 1 THEN column_a
+ WHEN 2 = 2 THEN column_a * column_a
+ WHEN 2 = 3 THEN column_a * column_a * column_a
+ WHEN 2 = 4 THEN column_a * column_a * column_a * column_a
+ WHEN 2 = 0.5 THEN
+ -- Square root case using Newton's method
+ CASE
+ WHEN column_a <= 0 THEN 0
+ ELSE (column_a / 2.0 + column_a / (column_a / 2.0)) / 2.0
+ END
+ ELSE 1
+ END
+ )"
+`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement right function for SQLite 1`] = `"SUBSTR(column_a, -3)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 1`] = `"ROUND(column_a, 2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement round function for SQLite 2`] = `"ROUND(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 1`] = `
+"CAST(FLOOR(column_a * (
+ CASE
+ WHEN 2 = 0 THEN 1
+ WHEN 2 = 1 THEN 10
+ WHEN 2 = 2 THEN 100
+ WHEN 2 = 3 THEN 1000
+ WHEN 2 = 4 THEN 10000
+ ELSE 1
+ END
+ )) / (
+ CASE
+ WHEN 2 = 0 THEN 1
+ WHEN 2 = 1 THEN 10
+ WHEN 2 = 2 THEN 100
+ WHEN 2 = 3 THEN 1000
+ WHEN 2 = 4 THEN 10000
+ ELSE 1
+ END
+ ) AS REAL)"
+`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundDown function for SQLite 2`] = `"CAST(FLOOR(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 1`] = `
+"CAST(CEIL(column_a * (
+ CASE
+ WHEN 2 = 0 THEN 1
+ WHEN 2 = 1 THEN 10
+ WHEN 2 = 2 THEN 100
+ WHEN 2 = 3 THEN 1000
+ WHEN 2 = 4 THEN 10000
+ ELSE 1
+ END
+ )) / (
+ CASE
+ WHEN 2 = 0 THEN 1
+ WHEN 2 = 1 THEN 10
+ WHEN 2 = 2 THEN 100
+ WHEN 2 = 3 THEN 1000
+ WHEN 2 = 4 THEN 10000
+ ELSE 1
+ END
+ ) AS REAL)"
+`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement roundUp function for SQLite 2`] = `"CAST(CEIL(column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 1`] = `"INSTR(UPPER(column_a), UPPER('text'))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement search function for SQLite 2`] = `"CASE WHEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) > 0 THEN INSTR(UPPER(SUBSTR(column_a, 3)), UPPER('text')) + 3 - 1 ELSE 0 END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sqrt function for SQLite 1`] = `
+"(
+ CASE
+ WHEN column_a <= 0 THEN 0
+ ELSE (column_a / 2.0 + column_a / (column_a / 2.0)) / 2.0
+ END
+ )"
+`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement substitute function for SQLite 1`] = `"REPLACE(column_a, 'old', 'new')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement sum function for SQLite 1`] = `"(column_a + column_b + 10)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement today function for SQLite 1`] = `"DATE('now')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement trim function for SQLite 1`] = `"TRIM(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement upper function for SQLite 1`] = `"UPPER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > SQLite Generated Column Functions > All Functions > should implement year function for SQLite 1`] = `"CAST(STRFTIME('%Y', column_a) AS INTEGER)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement autoNumber function 1`] = `""__auto_number""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement recordId function 1`] = `""__id""`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > System Functions > should implement textAll function 1`] = `"ARRAY_TO_STRING(column_a, ', ')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement concatenate function 1`] = `"(COALESCE(column_a::text, '') || COALESCE(' - '::text, '') || COALESCE(column_b::text, ''))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement encodeUrlComponent function 1`] = `"encode(column_a::bytea, 'escape')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 1`] = `"POSITION('text' IN column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement find function with optional parameters 2`] = `"POSITION('text' IN SUBSTRING(column_a FROM 5::integer)) + 5::integer - 1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement left function 1`] = `"LEFT(column_a, 5::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement len function 1`] = `"LENGTH(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement lower function 1`] = `"LOWER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement mid function 1`] = `"SUBSTRING(column_a FROM 2::integer FOR 5::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement regexpReplace function 1`] = `"REGEXP_REPLACE((column_a)::text, ('pattern')::text, ('replacement')::text, 'g')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement replace function 1`] = `"OVERLAY(column_a PLACING 'new' FROM 2::integer FOR 3::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement rept function 1`] = `"REPEAT(column_a, 3::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement right function 1`] = `"RIGHT(column_a, 3::integer)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 1`] = `"POSITION(UPPER('text') IN UPPER(column_a))"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement search function with optional parameters 2`] = `"POSITION(UPPER('text') IN UPPER(SUBSTRING(column_a FROM 3::integer))) + 3::integer - 1"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 1`] = `"REPLACE(column_a, 'old', 'new')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement substitute function with optional parameters 2`] = `"REPLACE(column_a, 'old', 'new')"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement t function 1`] = `"CASE WHEN column_a IS NULL THEN '' ELSE column_a::text END"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement trim function 1`] = `"TRIM(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Text Functions > should implement upper function 1`] = `"UPPER(column_a)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement add operation 1`] = `"(column_a + column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement bitwiseAnd operation 1`] = `
+"(
+ CASE
+ WHEN column_a::text ~ '^-?[0-9]+$' AND column_a::text != '' THEN column_a::integer
+ ELSE 0
+ END &
+ CASE
+ WHEN column_b::text ~ '^-?[0-9]+$' AND column_b::text != '' THEN column_b::integer
+ ELSE 0
+ END
+ )"
+`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToBoolean operation 1`] = `"column_a::boolean"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToDate operation 1`] = `"column_a::timestamp"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToNumber operation 1`] = `"column_a::numeric"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement castToString operation 1`] = `"column_a::text"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement divide operation 1`] = `"(column_a / column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement equal operation 1`] = `"(column_a = column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThan operation 1`] = `"(column_a > 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement greaterThanOrEqual operation 1`] = `"(column_a >= 0)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThan operation 1`] = `"(column_a < 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement lessThanOrEqual operation 1`] = `"(column_a <= 100)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalAnd operation 1`] = `"(condition1 AND condition2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement logicalOr operation 1`] = `"(condition1 OR condition2)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement modulo operation 1`] = `"(column_a % column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement multiply operation 1`] = `"(column_a * column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement notEqual operation 1`] = `"(column_a <> column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement parentheses operation 1`] = `"(expression)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement subtract operation 1`] = `"(column_a - column_b)"`;
+
+exports[`GeneratedColumnQuery > PostgreSQL Generated Column Functions > Type Casting and Operations > should implement unaryMinus operation 1`] = `"(-column_a)"`;
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/sql-conversion.spec.ts.snap b/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/sql-conversion.spec.ts.snap
new file mode 100644
index 0000000000..2258baf837
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/__snapshots__/sql-conversion.spec.ts.snap
@@ -0,0 +1,1051 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 1`] = `
+{
+ "dependencies": [
+ "numField",
+ ],
+ "sql": "("num_col" + "num_col")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 2`] = `
+{
+ "dependencies": [
+ "textField",
+ ],
+ "sql": "("text_col" || "text_col")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 3`] = `
+{
+ "dependencies": [
+ "textField",
+ "numField",
+ ],
+ "sql": "("text_col" || "num_col")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 4`] = `
+{
+ "dependencies": [
+ "numField",
+ "textField",
+ ],
+ "sql": "("num_col" || "text_col")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 5`] = `
+{
+ "dependencies": [
+ "boolField",
+ "numField",
+ ],
+ "sql": "("bool_col" + "num_col")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should correctly infer types for complex expressions 6`] = `
+{
+ "dependencies": [
+ "dateField",
+ "textField",
+ ],
+ "sql": "("date_col" || "text_col")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for "test string" 1`] = `
+{
+ "dependencies": [],
+ "sql": "'test string'",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for ({fld1} + {fld2}) 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld2",
+ ],
+ "sql": "(("column_a" || "column_b"))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} != {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" <> "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} % {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" % "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} & {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" & "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} * {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" * "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} / {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" / "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} < {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" < "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} <= {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" <= "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} <> {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": ""column_a"",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} = {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" = "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} > {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" > "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} >= {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" >= "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld1} - {fld3} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld3",
+ ],
+ "sql": "("column_a" - "column_c")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld5} && {fld1} > 0 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ "fld1",
+ ],
+ "sql": "("column_e" AND ("column_a" > 0))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for {fld5} || {fld1} > 0 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ "fld1",
+ ],
+ "sql": "("column_e" OR ("column_a" > 0))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for -{fld1} 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "(-"column_a")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for 3.14 1`] = `
+{
+ "dependencies": [],
+ "sql": "3.14",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for 42 1`] = `
+{
+ "dependencies": [],
+ "sql": "42",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for FALSE 1`] = `
+{
+ "dependencies": [],
+ "sql": "FALSE",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Advanced Tests > should handle visitor method for TRUE 1`] = `
+{
+ "dependencies": [],
+ "sql": "TRUE",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function CREATED_TIME() for PostgreSQL 1`] = `
+{
+ "dependencies": [],
+ "sql": "__created_time__",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function DAY({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(DAY FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function DAY({fld6}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "CAST(STRFTIME('%d', \`column_f\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function HOUR({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(HOUR FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function HOUR({fld6}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "CAST(STRFTIME('%H', \`column_f\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function IS_SAME({fld6}, NOW(), "day") for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "DATE_TRUNC('day', "column_f"::timestamp) = DATE_TRUNC('day', NOW()::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function LAST_MODIFIED_TIME() for PostgreSQL 1`] = `
+{
+ "dependencies": [],
+ "sql": "__last_modified_time__",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MINUTE({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(MINUTE FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MINUTE({fld6}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "CAST(STRFTIME('%M', \`column_f\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MONTH({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(MONTH FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function MONTH({fld6}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "CAST(STRFTIME('%m', \`column_f\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function SECOND({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(SECOND FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function SECOND({fld6}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "CAST(STRFTIME('%S', \`column_f\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function TODAY() for PostgreSQL 1`] = `
+{
+ "dependencies": [],
+ "sql": "CURRENT_DATE",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function TODAY() for SQLite 1`] = `
+{
+ "dependencies": [],
+ "sql": "DATE('now')",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WEEKDAY({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(DOW FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WEEKNUM({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(WEEK FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WORKDAY({fld6}, 5) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": ""column_f"::date + INTERVAL '1 day' * 5::integer",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function WORKDAY_DIFF({fld6}, NOW()) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "NOW()::date - "column_f"::date",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function YEAR({fld6}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "EXTRACT(YEAR FROM "column_f"::timestamp)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Date Functions > should convert date function YEAR({fld6}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld6",
+ ],
+ "sql": "CAST(STRFTIME('%Y', \`column_f\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ABS({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ABS("column_a"::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ABS({fld1}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ABS(\`column_a\`)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function CEILING({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CEIL("column_a"::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function CEILING({fld1}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CAST(CEIL(\`column_a\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function EVEN({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CASE WHEN "column_a"::integer % 2 = 0 THEN "column_a"::integer ELSE "column_a"::integer + 1 END",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function EXP({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "EXP("column_a"::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function EXP({fld1}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "EXP(\`column_a\`)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function FLOOR({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "FLOOR("column_a"::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function FLOOR({fld1}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CAST(FLOOR(\`column_a\`) AS INTEGER)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function INT({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "FLOOR("column_a"::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function LOG({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "LN("column_a"::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function LOG({fld1}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "LN(\`column_a\`)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function MOD({fld1}, 3) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "MOD("column_a"::numeric, 3::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function MOD({fld1}, 3) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "(\`column_a\` % 3)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ODD({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CASE WHEN "column_a"::integer % 2 = 1 THEN "column_a"::integer ELSE "column_a"::integer + 1 END",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function POWER({fld1}, 2) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "POWER("column_a"::numeric, 2::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function POWER({fld1}, 2) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "POWER(\`column_a\`, 2)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDDOWN({fld1}, 1) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "FLOOR("column_a"::numeric * POWER(10, 1::integer)) / POWER(10, 1::integer)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDDOWN({fld1}, 1) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CAST(FLOOR(\`column_a\` * POWER(10, 1)) / POWER(10, 1) AS REAL)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDUP({fld1}, 2) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CEIL("column_a"::numeric * POWER(10, 2::integer)) / POWER(10, 2::integer)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function ROUNDUP({fld1}, 2) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CAST(CEIL(\`column_a\` * POWER(10, 2)) / POWER(10, 2) AS REAL)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function SQRT({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "SQRT("column_a"::numeric)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function SQRT({fld1}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "SQRT(\`column_a\`)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Numeric Functions > should convert numeric function VALUE({fld2}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": ""column_b"::numeric",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AND({fld5}, {fld1} > 0) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ "fld1",
+ ],
+ "sql": "("column_e" AND ("column_a" > 0))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AND({fld5}, {fld1} > 0) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ "fld1",
+ ],
+ "sql": "(\`column_e\` AND (\`column_a\` > 0))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_COMPACT({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ARRAY(SELECT x FROM UNNEST("column_a") AS x WHERE x IS NOT NULL)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_FLATTEN({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ARRAY(SELECT UNNEST("column_a"))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_JOIN({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ARRAY_TO_STRING("column_a", ', ')",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_JOIN({fld1}, " | ") for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ARRAY_TO_STRING("column_a", ' | ')",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function ARRAY_UNIQUE({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ARRAY(SELECT DISTINCT UNNEST("column_a"))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AUTO_NUMBER() for PostgreSQL 1`] = `
+{
+ "dependencies": [],
+ "sql": "__auto_number",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function AUTO_NUMBER() for SQLite 1`] = `
+{
+ "dependencies": [],
+ "sql": "__auto_number",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function BLANK() for PostgreSQL 1`] = `
+{
+ "dependencies": [],
+ "sql": "NULL",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function BLANK() for SQLite 1`] = `
+{
+ "dependencies": [],
+ "sql": "NULL",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNT({fld1}, {fld2}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld2",
+ ],
+ "sql": "(CASE WHEN \`column_a\` IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN \`column_b\` IS NOT NULL THEN 1 ELSE 0 END)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNT({fld1}, {fld2}, {fld3}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld2",
+ "fld3",
+ ],
+ "sql": "(CASE WHEN "column_a" IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN "column_b" IS NOT NULL THEN 1 ELSE 0 END + CASE WHEN "column_c" IS NOT NULL THEN 1 ELSE 0 END)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNTA({fld1}, {fld2}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ "fld2",
+ ],
+ "sql": "(CASE WHEN \"column_a\" IS NULL OR COALESCE(NULLIF((\"column_a\")::text, ''), '') = '' THEN 0 ELSE 1 END + CASE WHEN \"column_b\" IS NULL OR COALESCE(NULLIF((\"column_b\")::text, ''), '') = '' THEN 0 ELSE 1 END)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function COUNTALL({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CASE WHEN "column_a" IS NULL THEN 0 ELSE 1 END",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function IS_ERROR({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CASE WHEN "column_a" IS NULL THEN TRUE ELSE FALSE END",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function IS_ERROR({fld1}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CASE WHEN \`column_a\` IS NULL THEN 1 ELSE 0 END",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function NOT({fld5}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ ],
+ "sql": "NOT ("column_e")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function NOT({fld5}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ ],
+ "sql": "NOT (\`column_e\`)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function OR({fld5}, {fld1} < 0) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ "fld1",
+ ],
+ "sql": "("column_e" OR ("column_a" < 0))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function OR({fld5}, {fld1} < 0) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ "fld1",
+ ],
+ "sql": "(\`column_e\` OR (\`column_a\` < 0))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function RECORD_ID() for PostgreSQL 1`] = `
+{
+ "dependencies": [],
+ "sql": "__id",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function RECORD_ID() for SQLite 1`] = `
+{
+ "dependencies": [],
+ "sql": "__id",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function TEXT_ALL({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "ARRAY_TO_STRING("column_a", ', ')",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Other Functions > should convert function XOR({fld5}, {fld1} > 0) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld5",
+ "fld1",
+ ],
+ "sql": "(("column_e") AND NOT (("column_a" > 0))) OR (NOT ("column_e") AND (("column_a" > 0)))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function FIND("test", {fld2}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "POSITION('test' IN "column_b")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function FIND("test", {fld2}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "INSTR(\`column_b\`, 'test')",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function FIND("test", {fld2}, 5) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "POSITION('test' IN SUBSTRING("column_b" FROM 5::integer)) + 5::integer - 1",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEFT({fld2}, 3) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "LEFT("column_b", 3::integer)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEFT({fld2}, 3) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "SUBSTR(\`column_b\`, 1, 3)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEN({fld2}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "LENGTH("column_b")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function LEN({fld2}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "LENGTH(\`column_b\`)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function MID({fld2}, 2, 5) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "SUBSTRING("column_b" FROM 2::integer FOR 5::integer)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function MID({fld2}, 2, 5) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "SUBSTR(\`column_b\`, 2, 5)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function REPLACE({fld2}, 1, 2, "new") for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "OVERLAY("column_b" PLACING 'new' FROM 1::integer FOR 2::integer)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function REPT({fld2}, 3) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "REPEAT("column_b", 3::integer)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function RIGHT({fld2}, 3) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "RIGHT("column_b", 3::integer)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function RIGHT({fld2}, 3) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "SUBSTR(\`column_b\`, -3)",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SEARCH("test", {fld2}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "POSITION(UPPER('test') IN UPPER("column_b"))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SEARCH("test", {fld2}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "INSTR(UPPER(\`column_b\`), UPPER('test'))",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SUBSTITUTE({fld2}, "old", "new") for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "REPLACE("column_b", 'old', 'new')",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function SUBSTITUTE({fld2}, "old", "new") for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "REPLACE(\`column_b\`, 'old', 'new')",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function T({fld1}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld1",
+ ],
+ "sql": "CASE WHEN "column_a" IS NULL THEN '' ELSE "column_a"::text END",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function TRIM({fld2}) for PostgreSQL 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "TRIM("column_b")",
+}
+`;
+
+exports[`Generated Column Query End-to-End Tests > Comprehensive Function Coverage Tests > All Text Functions > should convert text function TRIM({fld2}) for SQLite 1`] = `
+{
+ "dependencies": [
+ "fld2",
+ ],
+ "sql": "TRIM(\`column_b\`)",
+}
+`;
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query-support-validator.spec.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query-support-validator.spec.ts
new file mode 100644
index 0000000000..9670f61bac
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query-support-validator.spec.ts
@@ -0,0 +1,176 @@
+import { GeneratedColumnQuerySupportValidatorPostgres } from './postgres/generated-column-query-support-validator.postgres';
+import { GeneratedColumnQuerySupportValidatorSqlite } from './sqlite/generated-column-query-support-validator.sqlite';
+
+describe('GeneratedColumnQuerySupportValidator', () => {
+ let postgresValidator: GeneratedColumnQuerySupportValidatorPostgres;
+ let sqliteValidator: GeneratedColumnQuerySupportValidatorSqlite;
+
+ beforeEach(() => {
+ postgresValidator = new GeneratedColumnQuerySupportValidatorPostgres();
+ sqliteValidator = new GeneratedColumnQuerySupportValidatorSqlite();
+ });
+
+ describe('PostgreSQL Support Validator', () => {
+ it('should support basic numeric functions', () => {
+ expect(postgresValidator.sum(['a', 'b'])).toBe(true);
+ expect(postgresValidator.average(['a', 'b'])).toBe(true);
+ expect(postgresValidator.max(['a', 'b'])).toBe(true);
+ expect(postgresValidator.min(['a', 'b'])).toBe(true);
+ expect(postgresValidator.round('a', '2')).toBe(true);
+ expect(postgresValidator.abs('a')).toBe(true);
+ expect(postgresValidator.sqrt('a')).toBe(true);
+ expect(postgresValidator.power('a', 'b')).toBe(true);
+ });
+
+ it('should support basic text functions', () => {
+ expect(postgresValidator.concatenate(['a', 'b'])).toBe(true);
+ expect(postgresValidator.upper('a')).toBe(false); // Requires collation in PostgreSQL
+ expect(postgresValidator.lower('a')).toBe(false); // Requires collation in PostgreSQL
+ expect(postgresValidator.trim('a')).toBe(true);
+ expect(postgresValidator.len('a')).toBe(true);
+ expect(postgresValidator.regexpReplace('a', 'b', 'c')).toBe(false); // Not supported in generated columns
+ });
+
+ it('should not support array functions due to technical limitations', () => {
+ expect(postgresValidator.arrayJoin('a', ',')).toBe(false);
+ expect(postgresValidator.arrayUnique(['a'])).toBe(false);
+ expect(postgresValidator.arrayFlatten(['a'])).toBe(false);
+ expect(postgresValidator.arrayCompact(['a'])).toBe(false);
+ });
+
+ it('should support basic time functions but not time-dependent ones', () => {
+ expect(postgresValidator.now()).toBe(true);
+ expect(postgresValidator.today()).toBe(true);
+ expect(postgresValidator.lastModifiedTime()).toBe(false);
+ expect(postgresValidator.createdTime()).toBe(false);
+ expect(postgresValidator.fromNow('a')).toBe(false);
+ expect(postgresValidator.toNow('a')).toBe(false);
+ });
+
+ it('should support system functions', () => {
+ expect(postgresValidator.recordId()).toBe(false);
+ expect(postgresValidator.autoNumber()).toBe(false);
+ });
+
+ it('should support basic date functions but not complex ones', () => {
+ expect(postgresValidator.dateAdd('a', 'b', 'c')).toBe(false);
+ expect(postgresValidator.datetimeDiff('a', 'b', 'c')).toBe(false); // Not immutable in PostgreSQL
+ expect(postgresValidator.year('a')).toBe(false); // Not immutable in PostgreSQL
+ expect(postgresValidator.month('a')).toBe(false); // Not immutable in PostgreSQL
+ expect(postgresValidator.day('a')).toBe(false); // Not immutable in PostgreSQL
+ expect(postgresValidator.workday('a', 'b')).toBe(false);
+ expect(postgresValidator.workdayDiff('a', 'b')).toBe(false);
+ });
+ });
+
+ describe('SQLite Support Validator', () => {
+ it('should support basic numeric functions', () => {
+ expect(sqliteValidator.sum(['a', 'b'])).toBe(true);
+ expect(sqliteValidator.average(['a', 'b'])).toBe(true);
+ expect(sqliteValidator.max(['a', 'b'])).toBe(true);
+ expect(sqliteValidator.min(['a', 'b'])).toBe(true);
+ expect(sqliteValidator.round('a', '2')).toBe(true);
+ expect(sqliteValidator.abs('a')).toBe(true);
+ });
+
+ it('should not support advanced numeric functions', () => {
+ expect(sqliteValidator.sqrt('a')).toBe(true); // SQLite SQRT is implemented
+ expect(sqliteValidator.power('a', 'b')).toBe(true); // SQLite POWER is implemented
+ expect(sqliteValidator.exp('a')).toBe(false);
+ expect(sqliteValidator.log('a', 'b')).toBe(false);
+ });
+
+ it('should support basic text functions', () => {
+ expect(sqliteValidator.concatenate(['a', 'b'])).toBe(true);
+ expect(sqliteValidator.upper('a')).toBe(true);
+ expect(sqliteValidator.lower('a')).toBe(true);
+ expect(sqliteValidator.trim('a')).toBe(true);
+ expect(sqliteValidator.len('a')).toBe(true);
+ });
+
+ it('should not support advanced text functions', () => {
+ expect(sqliteValidator.regexpReplace('a', 'b', 'c')).toBe(false);
+ expect(sqliteValidator.rept('a', '3')).toBe(false);
+ expect(sqliteValidator.encodeUrlComponent('a')).toBe(false);
+ });
+
+ it('should not support array functions', () => {
+ expect(sqliteValidator.arrayJoin('a', ',')).toBe(false);
+ expect(sqliteValidator.arrayUnique(['a'])).toBe(false);
+ expect(sqliteValidator.arrayFlatten(['a'])).toBe(false);
+ expect(sqliteValidator.arrayCompact(['a'])).toBe(false);
+ });
+
+ it('should support basic time functions but not time-dependent ones', () => {
+ expect(sqliteValidator.now()).toBe(true);
+ expect(sqliteValidator.today()).toBe(true);
+ expect(sqliteValidator.lastModifiedTime()).toBe(false);
+ expect(sqliteValidator.createdTime()).toBe(false);
+ expect(sqliteValidator.fromNow('a')).toBe(false);
+ expect(sqliteValidator.toNow('a')).toBe(false);
+ });
+
+ it('should support system functions', () => {
+ expect(sqliteValidator.recordId()).toBe(false);
+ expect(sqliteValidator.autoNumber()).toBe(false);
+ });
+
+ it('should not support complex date functions', () => {
+ expect(sqliteValidator.workday('a', 'b')).toBe(false);
+ expect(sqliteValidator.workdayDiff('a', 'b')).toBe(false);
+ expect(sqliteValidator.datetimeParse('a', 'b')).toBe(false);
+ });
+
+ it('should support basic date functions', () => {
+ expect(sqliteValidator.dateAdd('a', 'b', 'c')).toBe(false);
+ expect(sqliteValidator.datetimeDiff('a', 'b', 'c')).toBe(true);
+ expect(sqliteValidator.year('a')).toBe(false); // Not immutable in SQLite
+ expect(sqliteValidator.month('a')).toBe(false); // Not immutable in SQLite
+ expect(sqliteValidator.day('a')).toBe(false); // Not immutable in SQLite
+ });
+ });
+
+ describe('Comparison between PostgreSQL and SQLite', () => {
+ it('should show PostgreSQL has more capabilities than SQLite', () => {
+ // Functions that PostgreSQL supports but SQLite doesn't
+ const postgresOnlyFunctions = [
+ // Note: sqrt and power are now supported in both PostgreSQL and SQLite
+ // regexpReplace, encodeUrlComponent, and datetimeParse are not supported in PostgreSQL generated columns
+ () => postgresValidator.exp('a') && !sqliteValidator.exp('a'),
+ () => postgresValidator.log('a', 'b') && !sqliteValidator.log('a', 'b'),
+ () => postgresValidator.rept('a', '3') && !sqliteValidator.rept('a', '3'),
+ ];
+
+ postgresOnlyFunctions.forEach((testFn) => {
+ expect(testFn()).toBe(true);
+ });
+ });
+
+ it('should have same restrictions for error handling and unpredictable time functions', () => {
+ // Both should reject these functions
+ const restrictedFunctions = [
+ 'fromNow',
+ 'toNow',
+ 'error',
+ 'isError',
+ 'workday',
+ 'workdayDiff',
+ 'arrayJoin',
+ 'arrayUnique',
+ 'arrayFlatten',
+ 'arrayCompact',
+ ] as const;
+
+ restrictedFunctions.forEach((funcName) => {
+ const arg = funcName.startsWith('array') && funcName !== 'arrayJoin' ? ['test'] : 'test';
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const postgresResult = (postgresValidator as any)[funcName](arg);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const sqliteResult = (sqliteValidator as any)[funcName](arg);
+ expect(postgresResult).toBe(false);
+ expect(sqliteResult).toBe(false);
+ expect(postgresResult).toBe(sqliteResult);
+ });
+ });
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query.abstract.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query.abstract.ts
new file mode 100644
index 0000000000..95252846d2
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/generated-column-query.abstract.ts
@@ -0,0 +1,250 @@
+import type { IFormulaParamMetadata } from '@teable/core';
+import type {
+ IFormulaConversionContext,
+ IGeneratedColumnQueryInterface,
+} from '../../features/record/query-builder/sql-conversion.visitor';
+
+/**
+ * Abstract base class for generated column query implementations
+ * Provides common functionality and default implementations for converting
+ * Teable formula expressions to database-specific SQL suitable for generated columns
+ */
+export abstract class GeneratedColumnQueryAbstract implements IGeneratedColumnQueryInterface {
+ /** Current conversion context */
+ protected context?: IFormulaConversionContext;
+ protected currentCallMetadata?: IFormulaParamMetadata[];
+
+ /** Set the conversion context */
+ setContext(context: IFormulaConversionContext): void {
+ this.context = context;
+ }
+
+ setCallMetadata(metadata?: IFormulaParamMetadata[]): void {
+ this.currentCallMetadata = metadata;
+ }
+
+ /** Check if we're in a generated column context */
+ protected get isGeneratedColumnContext(): boolean {
+ return this.context?.isGeneratedColumn ?? false;
+ }
+ // Numeric Functions
+ abstract sum(params: string[]): string;
+ abstract average(params: string[]): string;
+ abstract max(params: string[]): string;
+ abstract min(params: string[]): string;
+ abstract round(value: string, precision?: string): string;
+ abstract roundUp(value: string, precision?: string): string;
+ abstract roundDown(value: string, precision?: string): string;
+ abstract ceiling(value: string): string;
+ abstract floor(value: string): string;
+ abstract even(value: string): string;
+ abstract odd(value: string): string;
+ abstract int(value: string): string;
+ abstract abs(value: string): string;
+ abstract sqrt(value: string): string;
+ abstract power(base: string, exponent: string): string;
+ abstract exp(value: string): string;
+ abstract log(value: string, base?: string): string;
+ abstract mod(dividend: string, divisor: string): string;
+ abstract value(text: string): string;
+
+ // Text Functions
+ abstract concatenate(params: string[]): string;
+ abstract stringConcat(left: string, right: string): string;
+ abstract find(searchText: string, withinText: string, startNum?: string): string;
+ abstract search(searchText: string, withinText: string, startNum?: string): string;
+ abstract mid(text: string, startNum: string, numChars: string): string;
+ abstract left(text: string, numChars: string): string;
+ abstract right(text: string, numChars: string): string;
+ abstract replace(oldText: string, startNum: string, numChars: string, newText: string): string;
+ abstract regexpReplace(text: string, pattern: string, replacement: string): string;
+ abstract substitute(text: string, oldText: string, newText: string, instanceNum?: string): string;
+ abstract lower(text: string): string;
+ abstract upper(text: string): string;
+ abstract rept(text: string, numTimes: string): string;
+ abstract trim(text: string): string;
+ abstract len(text: string): string;
+ abstract t(value: string): string;
+ abstract encodeUrlComponent(text: string): string;
+
+ // DateTime Functions
+ abstract now(): string;
+ abstract today(): string;
+ abstract dateAdd(date: string, count: string, unit: string): string;
+ abstract datestr(date: string): string;
+ abstract datetimeDiff(startDate: string, endDate: string, unit: string): string;
+ abstract datetimeFormat(date: string, format: string): string;
+ abstract datetimeParse(dateString: string, format?: string): string;
+ abstract day(date: string): string;
+ abstract fromNow(date: string, unit?: string): string;
+ abstract hour(date: string): string;
+ abstract isAfter(date1: string, date2: string): string;
+ abstract isBefore(date1: string, date2: string): string;
+ abstract isSame(date1: string, date2: string, unit?: string): string;
+ abstract lastModifiedTime(): string;
+ abstract minute(date: string): string;
+ abstract month(date: string): string;
+ abstract second(date: string): string;
+ abstract timestr(date: string): string;
+ abstract toNow(date: string, unit?: string): string;
+ abstract weekNum(date: string): string;
+ abstract weekday(date: string, startDayOfWeek?: string): string;
+ abstract workday(startDate: string, days: string, holidayStr?: string): string;
+ abstract workdayDiff(startDate: string, endDate: string): string;
+ abstract year(date: string): string;
+ abstract createdTime(): string;
+
+ // Logical Functions
+ abstract if(condition: string, valueIfTrue: string, valueIfFalse: string): string;
+ abstract and(params: string[]): string;
+ abstract or(params: string[]): string;
+ abstract not(value: string): string;
+ abstract xor(params: string[]): string;
+ abstract blank(): string;
+ abstract error(message: string): string;
+ abstract isError(value: string): string;
+ abstract switch(
+ expression: string,
+ cases: Array<{ case: string; result: string }>,
+ defaultResult?: string
+ ): string;
+
+ // Array Functions
+ abstract count(params: string[]): string;
+ abstract countA(params: string[]): string;
+ abstract countAll(value: string): string;
+ abstract arrayJoin(array: string, separator?: string): string;
+ abstract arrayUnique(arrays: string[]): string;
+ abstract arrayFlatten(arrays: string[]): string;
+ abstract arrayCompact(arrays: string[]): string;
+
+ // System Functions
+ abstract recordId(): string;
+ abstract autoNumber(): string;
+ abstract textAll(value: string): string;
+
+ // Binary Operations - Common implementations
+ add(left: string, right: string): string {
+ return `(${left} + ${right})`;
+ }
+
+ subtract(left: string, right: string): string {
+ return `(${left} - ${right})`;
+ }
+
+ multiply(left: string, right: string): string {
+ return `(${left} * ${right})`;
+ }
+
+ divide(left: string, right: string): string {
+ return `(${left} / ${right})`;
+ }
+
+ modulo(left: string, right: string): string {
+ return `(${left} % ${right})`;
+ }
+
+ // Comparison Operations - Common implementations
+ equal(left: string, right: string): string {
+ return `(${left} = ${right})`;
+ }
+
+ notEqual(left: string, right: string): string {
+ return `(${left} <> ${right})`;
+ }
+
+ greaterThan(left: string, right: string): string {
+ return `(${left} > ${right})`;
+ }
+
+ lessThan(left: string, right: string): string {
+ return `(${left} < ${right})`;
+ }
+
+ greaterThanOrEqual(left: string, right: string): string {
+ return `(${left} >= ${right})`;
+ }
+
+ lessThanOrEqual(left: string, right: string): string {
+ return `(${left} <= ${right})`;
+ }
+
+ // Logical Operations - Common implementations
+ logicalAnd(left: string, right: string): string {
+ return `(${left} AND ${right})`;
+ }
+
+ logicalOr(left: string, right: string): string {
+ return `(${left} OR ${right})`;
+ }
+
+ bitwiseAnd(left: string, right: string): string {
+ return `(${left} & ${right})`;
+ }
+
+ // Unary Operations - Common implementations
+ unaryMinus(value: string): string {
+ return `(-${value})`;
+ }
+
+ // Field Reference - Common implementation
+ abstract fieldReference(fieldId: string, columnName: string): string;
+
+ // Literals - Common implementations
+ stringLiteral(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`;
+ }
+
+ numberLiteral(value: number): string {
+ return value.toString();
+ }
+
+ booleanLiteral(value: boolean): string {
+ return value ? 'TRUE' : 'FALSE';
+ }
+
+ nullLiteral(): string {
+ return 'NULL';
+ }
+
+ // Utility methods - Common implementations
+ castToNumber(value: string): string {
+ return `CAST(${value} AS NUMERIC)`;
+ }
+
+ castToString(value: string): string {
+ return `CAST(${value} AS TEXT)`;
+ }
+
+ castToBoolean(value: string): string {
+ return `CAST(${value} AS BOOLEAN)`;
+ }
+
+ castToDate(value: string): string {
+ return `CAST(${value} AS TIMESTAMP)`;
+ }
+
+ // Handle null values
+ isNull(value: string): string {
+ return `(${value} IS NULL)`;
+ }
+
+ coalesce(params: string[]): string {
+ return `COALESCE(${params.join(', ')})`;
+ }
+
+ // Parentheses for grouping
+ parentheses(expression: string): string {
+ return `(${expression})`;
+ }
+
+ // Helper method to escape SQL identifiers
+ protected escapeIdentifier(identifier: string): string {
+ return `"${identifier.replace(/"/g, '""')}"`;
+ }
+
+ // Helper method to handle array parameters
+ protected joinParams(params: string[], separator = ', '): string {
+ return params.join(separator);
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/index.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/index.ts
new file mode 100644
index 0000000000..a0318326b9
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/index.ts
@@ -0,0 +1,11 @@
+import { DriverClient } from '@teable/core';
+import { match } from 'ts-pattern';
+import { GeneratedColumnQuerySupportValidatorPostgres } from './postgres/generated-column-query-support-validator.postgres';
+import { GeneratedColumnQuerySupportValidatorSqlite } from './sqlite/generated-column-query-support-validator.sqlite';
+
+export function createGeneratedColumnQuerySupportValidator(driver: DriverClient) {
+ return match(driver)
+ .with(DriverClient.Pg, () => new GeneratedColumnQuerySupportValidatorPostgres())
+ .with(DriverClient.Sqlite, () => new GeneratedColumnQuerySupportValidatorSqlite())
+ .exhaustive();
+}
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query-support-validator.postgres.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query-support-validator.postgres.ts
new file mode 100644
index 0000000000..130aad36e2
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query-support-validator.postgres.ts
@@ -0,0 +1,516 @@
+import type {
+ IFormulaConversionContext,
+ IGeneratedColumnQuerySupportValidator,
+} from '../../../features/record/query-builder/sql-conversion.visitor';
+
+/**
+ * PostgreSQL-specific implementation for validating generated column function support
+ * Returns true for functions that can be safely converted to PostgreSQL SQL expressions
+ * suitable for use in generated columns, false for unsupported functions.
+ */
+export class GeneratedColumnQuerySupportValidatorPostgres
+ implements IGeneratedColumnQuerySupportValidator
+{
+ private context?: IFormulaConversionContext;
+
+ setContext(context: IFormulaConversionContext): void {
+ this.context = context;
+ }
+
+ setCallMetadata(): void {
+ // No-op for validator
+ }
+
+ // Numeric Functions - PostgreSQL supports all basic numeric functions
+ sum(_params: string[]): boolean {
+ // Use addition instead of SUM() aggregation function
+ return true;
+ }
+
+ average(_params: string[]): boolean {
+ // Use addition and division instead of AVG() aggregation function
+ return true;
+ }
+
+ max(_params: string[]): boolean {
+ return true;
+ }
+
+ min(_params: string[]): boolean {
+ return true;
+ }
+
+ round(_value: string, _precision?: string): boolean {
+ return true;
+ }
+
+ roundUp(_value: string, _precision?: string): boolean {
+ return true;
+ }
+
+ roundDown(_value: string, _precision?: string): boolean {
+ return true;
+ }
+
+ ceiling(_value: string): boolean {
+ return true;
+ }
+
+ floor(_value: string): boolean {
+ return true;
+ }
+
+ even(_value: string): boolean {
+ return true;
+ }
+
+ odd(_value: string): boolean {
+ return true;
+ }
+
+ int(_value: string): boolean {
+ return true;
+ }
+
+ abs(_value: string): boolean {
+ return true;
+ }
+
+ sqrt(_value: string): boolean {
+ return true;
+ }
+
+ power(_base: string, _exponent: string): boolean {
+ return true;
+ }
+
+ exp(_value: string): boolean {
+ return true;
+ }
+
+ log(_value: string, _base?: string): boolean {
+ return true;
+ }
+
+ mod(_dividend: string, _divisor: string): boolean {
+ return true;
+ }
+
+ value(_text: string): boolean {
+ return true;
+ }
+
+ // Text Functions - PostgreSQL supports most text functions
+ concatenate(_params: string[]): boolean {
+ return true;
+ }
+
+ stringConcat(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ find(_searchText: string, _withinText: string, _startNum?: string): boolean {
+ // POSITION function requires collation in PostgreSQL
+ return false;
+ }
+
+ search(_searchText: string, _withinText: string, _startNum?: string): boolean {
+ // POSITION function requires collation in PostgreSQL
+ return false;
+ }
+
+ mid(_text: string, _startNum: string, _numChars: string): boolean {
+ return true;
+ }
+
+ left(_text: string, _numChars: string): boolean {
+ return true;
+ }
+
+ right(_text: string, _numChars: string): boolean {
+ return true;
+ }
+
+ replace(_oldText: string, _startNum: string, _numChars: string, _newText: string): boolean {
+ return true;
+ }
+
+ regexpReplace(_text: string, _pattern: string, _replacement: string): boolean {
+ // REGEXP_REPLACE is not supported in generated columns
+ return false;
+ }
+
+ substitute(_text: string, _oldText: string, _newText: string, _instanceNum?: string): boolean {
+ // REPLACE function requires collation in PostgreSQL
+ return false;
+ }
+
+ lower(_text: string): boolean {
+ // LOWER function requires collation for string literals in PostgreSQL
+ // Only supported when used with column references
+ return false;
+ }
+
+ upper(_text: string): boolean {
+ // UPPER function requires collation for string literals in PostgreSQL
+ // Only supported when used with column references
+ return false;
+ }
+
+ rept(_text: string, _numTimes: string): boolean {
+ return true;
+ }
+
+ trim(_text: string): boolean {
+ return true;
+ }
+
+ len(_text: string): boolean {
+ return true;
+ }
+
+ t(_value: string): boolean {
+ // T function implementation doesn't work correctly in PostgreSQL
+ return false;
+ }
+
+ encodeUrlComponent(_text: string): boolean {
+ // URL encoding is not supported in PostgreSQL generated columns
+ return false;
+ }
+
+ // DateTime Functions - Most are supported, some have limitations but are still usable
+ now(): boolean {
+ // now() is supported but results are fixed at creation time
+ return true;
+ }
+
+ today(): boolean {
+ // today() is supported but results are fixed at creation time
+ return true;
+ }
+
+ dateAdd(_date: string, _count: string, _unit: string): boolean {
+ // DATE_ADD relies on timestamp input parsing which is not immutable in PostgreSQL
+ // (casts depend on DateStyle/TimeZone). Treat as unsupported for generated columns.
+ return false;
+ }
+
+ datestr(_date: string): boolean {
+ // DATESTR with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ datetimeDiff(_startDate: string, _endDate: string, _unit: string): boolean {
+ // DATETIME_DIFF is not immutable in PostgreSQL
+ return false;
+ }
+
+ datetimeFormat(_date: string, _format: string): boolean {
+ // DATETIME_FORMAT is not immutable in PostgreSQL
+ return false;
+ }
+
+ datetimeParse(_dateString: string, _format?: string): boolean {
+ // DATETIME_PARSE is not immutable in PostgreSQL
+ return false;
+ }
+
+ day(_date: string): boolean {
+ // DAY with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ fromNow(_date: string): boolean {
+ // fromNow results are unpredictable due to fixed creation time
+ return false;
+ }
+
+ hour(_date: string): boolean {
+ // HOUR with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ isAfter(_date1: string, _date2: string): boolean {
+ // IS_AFTER is not immutable in PostgreSQL
+ return false;
+ }
+
+ isBefore(_date1: string, _date2: string): boolean {
+ // IS_BEFORE is not immutable in PostgreSQL
+ return false;
+ }
+
+ isSame(_date1: string, _date2: string, _unit?: string): boolean {
+ // IS_SAME is not immutable in PostgreSQL
+ return false;
+ }
+
+ lastModifiedTime(): boolean {
+ return false;
+ }
+
+ minute(_date: string): boolean {
+ // MINUTE with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ month(_date: string): boolean {
+ // MONTH with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ second(_date: string): boolean {
+ // SECOND with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ timestr(_date: string): boolean {
+ // TIMESTR with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ toNow(_date: string): boolean {
+ // toNow results are unpredictable due to fixed creation time
+ return false;
+ }
+
+ weekNum(_date: string): boolean {
+ // WEEKNUM with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ weekday(_date: string): boolean {
+ // WEEKDAY with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ workday(_startDate: string, _days: string): boolean {
+ // Complex weekend-skipping logic not implemented
+ return false;
+ }
+
+ workdayDiff(_startDate: string, _endDate: string): boolean {
+ // Complex business day calculation not implemented
+ return false;
+ }
+
+ year(_date: string): boolean {
+ // YEAR with column references is not immutable in PostgreSQL
+ return false;
+ }
+
+ createdTime(): boolean {
+ return false;
+ }
+
+ // Logical Functions - IF fallback to computed evaluation (not immutable-safe).
+ // Example: `IF({LinkField}, 1, 0)` dereferences JSON arrays from link cells and
+ // needs runtime truthiness checks; the generated expression is not immutable,
+ // so we force evaluation in the computed path instead of a generated column.
+ if(_condition: string, _valueIfTrue: string, _valueIfFalse: string): boolean {
+ return false;
+ }
+
+ and(_params: string[]): boolean {
+ return true;
+ }
+
+ or(_params: string[]): boolean {
+ return true;
+ }
+
+ not(_value: string): boolean {
+ return true;
+ }
+
+ xor(_params: string[]): boolean {
+ return true;
+ }
+
+ blank(): boolean {
+ return true;
+ }
+
+ error(_message: string): boolean {
+ // Cannot throw errors in generated column definitions
+ return false;
+ }
+
+ isError(_value: string): boolean {
+ // Cannot detect runtime errors in generated columns
+ return false;
+ }
+
+ switch(
+ _expression: string,
+ _cases: Array<{ case: string; result: string }>,
+ _defaultResult?: string
+ ): boolean {
+ return true;
+ }
+
+ // Array Functions - PostgreSQL supports basic array operations
+ count(_params: string[]): boolean {
+ return true;
+ }
+
+ countA(_params: string[]): boolean {
+ return true;
+ }
+
+ countAll(_value: string): boolean {
+ return true;
+ }
+
+ arrayJoin(_array: string, _separator?: string): boolean {
+ // JSONB vs Array type mismatch issue
+ return false;
+ }
+
+ arrayUnique(_arrays: string[]): boolean {
+ // Uses subqueries not allowed in generated columns
+ return false;
+ }
+
+ arrayFlatten(_arrays: string[]): boolean {
+ // Uses subqueries not allowed in generated columns
+ return false;
+ }
+
+ arrayCompact(_arrays: string[]): boolean {
+ // Uses subqueries not allowed in generated columns
+ return false;
+ }
+
+ // System Functions - Supported (reference system columns)
+ recordId(): boolean {
+ return false;
+ }
+
+ autoNumber(): boolean {
+ return false;
+ }
+
+ textAll(_value: string): boolean {
+ // textAll with non-array types causes function mismatch
+ return false;
+ }
+
+ // Binary Operations - All supported
+ add(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ subtract(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ multiply(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ divide(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ modulo(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ // Comparison Operations - All supported
+ equal(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ notEqual(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ greaterThan(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ lessThan(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ greaterThanOrEqual(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ lessThanOrEqual(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ // Logical Operations - All supported
+ logicalAnd(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ logicalOr(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ bitwiseAnd(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ // Unary Operations - All supported
+ unaryMinus(_value: string): boolean {
+ return true;
+ }
+
+ // Field Reference - Supported
+ fieldReference(_fieldId: string, _columnName: string): boolean {
+ return true;
+ }
+
+ // Literals - All supported
+ stringLiteral(_value: string): boolean {
+ return true;
+ }
+
+ numberLiteral(_value: number): boolean {
+ return true;
+ }
+
+ booleanLiteral(_value: boolean): boolean {
+ return true;
+ }
+
+ nullLiteral(): boolean {
+ return true;
+ }
+
+ // Utility methods - All supported
+ castToNumber(_value: string): boolean {
+ return true;
+ }
+
+ castToString(_value: string): boolean {
+ return true;
+ }
+
+ castToBoolean(_value: string): boolean {
+ return true;
+ }
+
+ castToDate(_value: string): boolean {
+ return true;
+ }
+
+ // Handle null values and type checking - All supported
+ isNull(_value: string): boolean {
+ return true;
+ }
+
+ coalesce(_params: string[]): boolean {
+ return true;
+ }
+
+ // Parentheses for grouping - Supported
+ parentheses(_expression: string): boolean {
+ return true;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.spec.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.spec.ts
new file mode 100644
index 0000000000..c857304451
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.spec.ts
@@ -0,0 +1,116 @@
+import { DbFieldType } from '@teable/core';
+import { describe, expect, it } from 'vitest';
+
+import { GeneratedColumnQueryPostgres } from './generated-column-query.postgres';
+
+describe('GeneratedColumnQueryPostgres if', () => {
+ it('coerces json-like numeric branches in IF to avoid CASE jsonb/integer mismatches', () => {
+ const query = new GeneratedColumnQueryPostgres();
+ query.setContext({} as unknown as never);
+ query.setCallMetadata([
+ { type: 'string', isFieldReference: false },
+ {
+ type: 'string',
+ isFieldReference: true,
+ field: {
+ id: 'fldJsonNumeric',
+ isMultiple: true,
+ isLookup: true,
+ dbFieldName: '__json_numeric',
+ dbFieldType: DbFieldType.Json,
+ cellValueType: 'number',
+ },
+ },
+ { type: 'number', isFieldReference: false },
+ ] as unknown as never);
+
+ const sql = query.if('__cond', '"__json_numeric"', '0');
+ expect(sql).toContain('to_jsonb("__json_numeric")');
+ expect(sql).toContain('jsonb_array_elements_text');
+ expect(sql).toContain('double precision');
+ });
+
+ it('counts multi-value json field elements in COUNTALL', () => {
+ const query = new GeneratedColumnQueryPostgres();
+ query.setContext({} as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'string',
+ isFieldReference: true,
+ field: {
+ id: 'fldMulti',
+ isMultiple: true,
+ isLookup: false,
+ dbFieldName: '__owners',
+ dbFieldType: DbFieldType.Json,
+ cellValueType: 'string',
+ },
+ },
+ ] as unknown as never);
+
+ const sql = query.countAll('"__owners"');
+ expect(sql).toContain('jsonb_array_length');
+ expect(sql).toContain(`NULLIF(("__owners")::jsonb, 'null'::jsonb)`);
+ });
+
+ it('keeps scalar COUNTALL behavior for non-json field', () => {
+ const query = new GeneratedColumnQueryPostgres();
+ query.setContext({} as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'number',
+ isFieldReference: true,
+ field: {
+ id: 'fldNumber',
+ isMultiple: false,
+ isLookup: false,
+ dbFieldName: '__number',
+ dbFieldType: DbFieldType.Real,
+ cellValueType: 'number',
+ },
+ },
+ ] as unknown as never);
+
+ expect(query.countAll('"__number"')).toBe('CASE WHEN "__number" IS NULL THEN 0 ELSE 1 END');
+ });
+});
+
+describe('GeneratedColumnQueryPostgres FROMNOW/TONOW', () => {
+ it('applies unit conversion for FROMNOW', () => {
+ const query = new GeneratedColumnQueryPostgres();
+ query.setContext({} as unknown as never);
+
+ const daySql = query.fromNow('NOW()', "'day'");
+ const hourSql = query.fromNow('NOW()', "'hour'");
+ const secondSql = query.fromNow('NOW()', "'second'");
+
+ expect(daySql).toContain('/ 86400');
+ expect(hourSql).toContain('/ 3600');
+ expect(secondSql).not.toContain('/ 86400');
+ expect(secondSql).not.toContain('/ 3600');
+ });
+
+ it('keeps TONOW direction as now minus date for past-positive semantics', () => {
+ const query = new GeneratedColumnQueryPostgres();
+ query.setContext({} as unknown as never);
+
+ const sql = query.toNow('NOW()', "'day'");
+ expect(sql).toContain('NOW() -');
+ expect(sql).not.toContain(' - NOW()');
+ });
+});
+
+describe('GeneratedColumnQueryPostgres DATETIME_PARSE', () => {
+ it('reparses trusted datetime inputs through explicit formats', () => {
+ const query = new GeneratedColumnQueryPostgres();
+ query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);
+ query.setCallMetadata([{ type: 'datetime', isFieldReference: false }] as unknown as never);
+
+ const sql = query.datetimeParse('column_a', "'MMYYYY'");
+
+ expect(sql).toContain('TO_CHAR');
+ expect(sql).toContain('TO_TIMESTAMP');
+ expect(sql).toContain(`AT TIME ZONE 'Asia/Shanghai'`);
+ expect(sql).not.toBe('(column_a)');
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.ts
new file mode 100644
index 0000000000..1018c81358
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/postgres/generated-column-query.postgres.ts
@@ -0,0 +1,1603 @@
+/* eslint-disable sonarjs/cognitive-complexity */
+/* eslint-disable regexp/no-unused-capturing-group */
+/* eslint-disable no-useless-escape */
+import { DbFieldType } from '@teable/core';
+import {
+ buildDatetimeFormatSql,
+ buildDatetimeParseGuardRegex,
+ hasDatetimeTimezoneToken,
+ normalizeDatetimeFormatExpression,
+} from '../../utils/datetime-format.util';
+import { getDefaultDatetimeParsePattern } from '../../utils/default-datetime-parse-pattern';
+import {
+ isBooleanLikeParam,
+ isDatetimeLikeParam,
+ isJsonLikeParam,
+ isTextLikeParam,
+ isTrustedNumeric,
+ resolveFormulaParamInfo,
+} from '../../utils/formula-param-metadata.util';
+import { GeneratedColumnQueryAbstract } from '../generated-column-query.abstract';
+
+/**
+ * PostgreSQL-specific implementation of generated column query functions
+ * Converts Teable formula functions to PostgreSQL SQL expressions suitable
+ * for use in generated columns. All generated SQL must be immutable.
+ */
+export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
+ private isEmptyStringLiteral(value: string): boolean {
+ return value.trim() === "''";
+ }
+
+ private isNullLiteral(value: string): boolean {
+ return this.stripOuterParentheses(value).toUpperCase() === 'NULL';
+ }
+
+ private shouldCoalesceNumericComparison(value: string, metadataIndex?: number): boolean {
+ if (this.isNumericLiteral(value)) {
+ return true;
+ }
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ return paramInfo ? isTrustedNumeric(paramInfo) || paramInfo.type === 'number' : false;
+ }
+
+ private normalizeNumericComparisonOperand(value: string, metadataIndex?: number): string {
+ if (!this.shouldCoalesceNumericComparison(value, metadataIndex)) {
+ return value;
+ }
+ return this.collapseNumeric(value, metadataIndex);
+ }
+
+ private hasWrappingParentheses(expr: string): boolean {
+ if (!expr.startsWith('(') || !expr.endsWith(')')) {
+ return false;
+ }
+ let depth = 0;
+ for (let i = 0; i < expr.length; i++) {
+ const ch = expr[i];
+ if (ch === '(') {
+ depth++;
+ } else if (ch === ')') {
+ depth--;
+ if (depth === 0 && i < expr.length - 1) {
+ return false;
+ }
+ if (depth < 0) {
+ return false;
+ }
+ }
+ }
+ return depth === 0;
+ }
+
+ private stripOuterParentheses(expr: string): string {
+ let trimmed = expr.trim();
+ while (trimmed.length > 0 && this.hasWrappingParentheses(trimmed)) {
+ trimmed = trimmed.slice(1, -1).trim();
+ }
+ return trimmed;
+ }
+
+ private getParamInfo(index?: number) {
+ return resolveFormulaParamInfo(this.currentCallMetadata, index);
+ }
+
+ private isNumericLiteral(expr: string): boolean {
+ let trimmed = this.stripOuterParentheses(expr);
+
+ // Peel leading signs while trimming redundant outer parens
+ while (trimmed.startsWith('+') || trimmed.startsWith('-')) {
+ trimmed = trimmed.slice(1).trim();
+ trimmed = this.stripOuterParentheses(trimmed);
+ }
+
+ // Match plain numeric literal, with optional cast to a numeric type
+ const numericWithOptionalCast =
+ /^\(?\d+(\.\d+)?\)?(::(double precision|numeric|real|integer|bigint|smallint))?$/i;
+ if (numericWithOptionalCast.test(trimmed)) {
+ return true;
+ }
+
+ // Handle wrapped casts like ((7)::double precision)
+ const wrappedCastMatch = trimmed.match(/^\((.+)\)$/);
+ if (wrappedCastMatch) {
+ return this.isNumericLiteral(wrappedCastMatch[1]);
+ }
+
+ return false;
+ }
+
+ private toNumericSafe(expr: string, metadataIndex?: number): string {
+ if (this.isNumericLiteral(expr)) {
+ return `(${expr})::double precision`;
+ }
+ const paramInfo = this.getParamInfo(metadataIndex);
+ const expressionFieldType = this.getExpressionFieldType(expr);
+ if (isBooleanLikeParam(paramInfo)) {
+ const normalizedBoolean = this.normalizeBooleanCondition(expr, metadataIndex ?? 0);
+ return `(CASE WHEN ${normalizedBoolean} THEN 1 ELSE 0 END)::double precision`;
+ }
+ if (
+ paramInfo?.hasMetadata &&
+ isTextLikeParam(paramInfo) &&
+ !paramInfo.isJsonField &&
+ !paramInfo.isMultiValueField
+ ) {
+ return this.looseNumericCoercion(expr);
+ }
+ if (expressionFieldType === DbFieldType.Text) {
+ return this.looseNumericCoercion(expr);
+ }
+ if (paramInfo?.isJsonField || paramInfo?.isMultiValueField) {
+ return this.numericFromJson(expr);
+ }
+ if (expressionFieldType === DbFieldType.Json) {
+ return this.numericFromJson(expr);
+ }
+ if (isTrustedNumeric(paramInfo)) {
+ return `(${expr})::double precision`;
+ }
+ if (
+ !paramInfo?.hasMetadata &&
+ (expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer)
+ ) {
+ return `(${expr})::double precision`;
+ }
+
+ if (!paramInfo && expressionFieldType === undefined) {
+ return `(${expr})::double precision`;
+ }
+
+ return this.looseNumericCoercion(expr);
+ }
+
+ private looseNumericCoercion(expr: string): string {
+ if (this.isNumericLiteral(expr)) {
+ return `(${expr})::double precision`;
+ }
+ const textExpr = `((${expr})::text) COLLATE "C"`;
+ const dateLikePattern = `'^[0-9]{1,4}[-/][0-9]{1,2}[-/][0-9]{1,4}( .*){0,1}$'`;
+ const collatedDatePattern = `${dateLikePattern} COLLATE "C"`;
+ const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;
+ const cleaned = `NULLIF(${sanitized}, '')`;
+ const collatedClean = `${cleaned} COLLATE "C"`;
+ // Avoid "?" in the regex so knex.raw doesn't misinterpret it as a binding placeholder.
+ const numericPattern = `'^[+-]{0,1}(\\d+(\\.\\d+){0,1}|\\.\\d+)$'`;
+ const collatedPattern = `${numericPattern} COLLATE "C"`;
+ return `(CASE
+ WHEN ${expr} IS NULL THEN NULL
+ WHEN ${textExpr} ~ ${collatedDatePattern} THEN NULL
+ WHEN ${cleaned} IS NULL THEN NULL
+ WHEN ${collatedClean} ~ ${collatedPattern} THEN ${cleaned}::double precision
+ ELSE NULL
+ END)`;
+ }
+
+ private numericFromJson(expr: string): string {
+ const jsonExpr = `to_jsonb(${expr})`;
+ const numericPattern = `'^[+-]{0,1}(\\d+(\\.\\d+){0,1}|\\.\\d+)$'`;
+ const collatedPattern = `${numericPattern} COLLATE "C"`;
+ const arraySum = `(SELECT SUM(CASE WHEN (elem.value COLLATE "C") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END) FROM jsonb_array_elements_text(${jsonExpr}) AS elem(value))`;
+ return `(CASE
+ WHEN ${expr} IS NULL THEN NULL
+ WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN ${arraySum}
+ ELSE ${this.looseNumericCoercion(expr)}
+ END)`;
+ }
+
+ private numericFromText(expr: string): string {
+ const textExpr = `((${expr})::text) COLLATE "C"`;
+ const numericPattern = `'^[+-]{0,1}(\\d+(\\.\\d+){0,1}|\\.\\d+)$'`;
+ const collatedPattern = `${numericPattern} COLLATE "C"`;
+ return `(CASE
+ WHEN ${expr} IS NULL THEN NULL
+ WHEN ${textExpr} ~ ${collatedPattern} THEN ${textExpr}::double precision
+ ELSE NULL
+ END)`;
+ }
+
+ private collapseNumeric(expr: string, metadataIndex?: number): string {
+ const numericValue = this.toNumericSafe(expr, metadataIndex);
+ return `COALESCE(${numericValue}, 0)`;
+ }
+
+ private normalizeBlankComparable(value: string, metadataIndex?: number): string {
+ const comparable = this.coerceToTextComparable(value, metadataIndex);
+ return `COALESCE(NULLIF(${comparable}, ''), '')`;
+ }
+
+ private ensureTextCollation(expr: string): string {
+ return `(${expr})::text`;
+ }
+
+ private buildBlankAwareComparison(
+ operator: '=' | '<>',
+ left: string,
+ right: string,
+ metadataIndexes?: { left?: number; right?: number }
+ ): string {
+ const leftIndex = metadataIndexes?.left;
+ const rightIndex = metadataIndexes?.right;
+ const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);
+ const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);
+ const leftIsText = this.isTextLikeExpression(left, leftIndex);
+ const rightIsText = this.isTextLikeExpression(right, rightIndex);
+ const normalizeText = leftIsEmptyLiteral || rightIsEmptyLiteral || leftIsText || rightIsText;
+
+ const leftIsNumericComparable = this.shouldCoalesceNumericComparison(left, leftIndex);
+ const rightIsNumericComparable = this.shouldCoalesceNumericComparison(right, rightIndex);
+
+ if (!normalizeText && (leftIsNumericComparable || rightIsNumericComparable)) {
+ const normalizedLeft = leftIsNumericComparable
+ ? this.normalizeNumericComparisonOperand(left, leftIndex)
+ : left;
+ const normalizedRight = rightIsNumericComparable
+ ? this.normalizeNumericComparisonOperand(right, rightIndex)
+ : right;
+ return `(${normalizedLeft} ${operator} ${normalizedRight})`;
+ }
+
+ if (!normalizeText) {
+ return `(${left} ${operator} ${right})`;
+ }
+
+ const normalizeOperand = (value: string, isEmptyLiteral: boolean, metadataIndex?: number) =>
+ isEmptyLiteral ? "''" : this.normalizeBlankComparable(value, metadataIndex);
+
+ const normalizedLeft = normalizeOperand(left, leftIsEmptyLiteral, leftIndex);
+ const normalizedRight = normalizeOperand(right, rightIsEmptyLiteral, rightIndex);
+
+ return `(${normalizedLeft} ${operator} ${normalizedRight})`;
+ }
+
+ private isTextLikeExpression(value: string, metadataIndex?: number): boolean {
+ const trimmed = this.stripOuterParentheses(value);
+ if (this.isEmptyStringLiteral(trimmed)) {
+ return false;
+ }
+ if (/^'.*'$/.test(trimmed)) {
+ return true;
+ }
+
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (paramInfo?.hasMetadata) {
+ if (
+ paramInfo.fieldDbType === DbFieldType.Real ||
+ paramInfo.fieldDbType === DbFieldType.Integer ||
+ paramInfo.fieldCellValueType === 'number'
+ ) {
+ return false;
+ }
+ if (isTextLikeParam(paramInfo)) {
+ return true;
+ }
+ }
+
+ return this.getExpressionFieldType(value) === DbFieldType.Text;
+ }
+
+ private isNumericLikeExpression(value: string, metadataIndex?: number): boolean {
+ if (this.isNumericLiteral(value)) {
+ return true;
+ }
+
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (paramInfo?.hasMetadata) {
+ if (
+ paramInfo.type === 'number' ||
+ isTrustedNumeric(paramInfo) ||
+ isBooleanLikeParam(paramInfo)
+ ) {
+ return true;
+ }
+ if (
+ paramInfo.fieldDbType === DbFieldType.Real ||
+ paramInfo.fieldDbType === DbFieldType.Integer
+ ) {
+ return true;
+ }
+ if (paramInfo.fieldCellValueType === 'number') {
+ return true;
+ }
+ }
+
+ const expressionFieldType = this.getExpressionFieldType(value);
+ return expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer;
+ }
+
+ private getExpressionFieldType(value: string): DbFieldType | undefined {
+ const trimmed = this.stripOuterParentheses(value);
+ const columnMatch = trimmed.match(/^"([^"]+)"$/) ?? trimmed.match(/^"[^"]+"\."([^"]+)"$/);
+ if (!columnMatch || columnMatch.length < 2) {
+ return undefined;
+ }
+
+ const columnName = columnMatch[1];
+ const table = this.context?.table;
+ const field =
+ table?.fieldList?.find((item) => item.dbFieldName === columnName) ??
+ table?.fields?.ordered?.find((item) => item.dbFieldName === columnName);
+ return field?.dbFieldType as DbFieldType | undefined;
+ }
+
+ private buildJsonScalarCoercion(jsonExpr: string): string {
+ const elementScalar = `CASE
+ WHEN jsonb_typeof(elem.value) = 'object' THEN COALESCE(
+ elem.value->>'title',
+ elem.value->>'name',
+ elem.value #>> '{}'
+ )
+ WHEN jsonb_typeof(elem.value) = 'array' THEN NULL
+ ELSE elem.value #>> '{}'
+ END`;
+
+ return `CASE jsonb_typeof(${jsonExpr})
+ WHEN 'string' THEN (${jsonExpr}) #>> '{}'
+ WHEN 'number' THEN (${jsonExpr}) #>> '{}'
+ WHEN 'boolean' THEN (${jsonExpr}) #>> '{}'
+ WHEN 'null' THEN NULL
+ WHEN 'array' THEN COALESCE((
+ SELECT STRING_AGG(${elementScalar}, ', ' ORDER BY elem.ordinality)
+ FROM jsonb_array_elements(${jsonExpr}) WITH ORDINALITY AS elem(value, ordinality)
+ ), '')
+ WHEN 'object' THEN COALESCE(${jsonExpr}->>'title', ${jsonExpr}->>'name', ${jsonExpr} #>> '{}')
+ ELSE (${jsonExpr})::text
+ END`;
+ }
+
+ private coerceJsonExpressionToText(wrapped: string): string {
+ const doubleWrapped = `(${wrapped})`;
+ const directJsonExpr = `${doubleWrapped}::jsonb`;
+ const fallbackJsonExpr = `to_jsonb${wrapped}`;
+ const jsonTypeGuard = `pg_typeof(${wrapped}) = ANY('{json,jsonb}'::regtype[])`;
+
+ return `(CASE
+ WHEN ${wrapped} IS NULL THEN NULL
+ WHEN ${jsonTypeGuard} THEN
+ ${this.buildJsonScalarCoercion(directJsonExpr)}
+ ELSE
+ ${this.buildJsonScalarCoercion(fallbackJsonExpr)}
+ END)`;
+ }
+
+ private coerceNonJsonExpressionToText(wrapped: string): string {
+ const jsonbValue = `to_jsonb${wrapped}`;
+
+ return `(CASE
+ WHEN ${wrapped} IS NULL THEN NULL
+ ELSE
+ ${this.buildJsonScalarCoercion(jsonbValue)}
+ END)`;
+ }
+
+ private coerceToTextComparable(value: string, metadataIndex?: number): string {
+ const trimmed = this.stripOuterParentheses(value);
+ if (!trimmed) {
+ return this.ensureTextCollation(value);
+ }
+ if (/^'.*'$/.test(trimmed)) {
+ return this.ensureTextCollation(trimmed);
+ }
+ if (trimmed.toUpperCase() === 'NULL') {
+ return 'NULL';
+ }
+
+ const wrapped = `(${value})`;
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ const expressionFieldType = this.getExpressionFieldType(value);
+ const numericField =
+ paramInfo?.fieldDbType === DbFieldType.Real ||
+ paramInfo?.fieldDbType === DbFieldType.Integer ||
+ paramInfo?.fieldCellValueType === 'number' ||
+ expressionFieldType === DbFieldType.Real ||
+ expressionFieldType === DbFieldType.Integer;
+ if (numericField && !paramInfo?.isJsonField && !paramInfo?.isMultiValueField) {
+ return wrapped;
+ }
+ const isJsonParam = paramInfo?.hasMetadata && isJsonLikeParam(paramInfo);
+ const shouldUseSimpleCast =
+ this.isGeneratedColumnContext &&
+ !isJsonParam &&
+ !paramInfo?.isMultiValueField &&
+ expressionFieldType !== DbFieldType.Json;
+
+ if (paramInfo?.hasMetadata) {
+ if (isJsonParam) {
+ if (shouldUseSimpleCast) {
+ return this.ensureTextCollation(`${wrapped}::text`);
+ }
+ const coercedJson = this.coerceJsonExpressionToText(wrapped);
+ return this.ensureTextCollation(coercedJson);
+ }
+
+ if (isTextLikeParam(paramInfo)) {
+ return this.ensureTextCollation(value);
+ }
+
+ if (paramInfo.type && paramInfo.type !== 'unknown') {
+ return this.ensureTextCollation(`${wrapped}::text`);
+ }
+ }
+
+ if (expressionFieldType === DbFieldType.Json) {
+ if (shouldUseSimpleCast) {
+ return this.ensureTextCollation(`${wrapped}::text`);
+ }
+ const coercedJson = this.coerceJsonExpressionToText(wrapped);
+ return this.ensureTextCollation(coercedJson);
+ }
+
+ if (expressionFieldType === DbFieldType.Text) {
+ return this.ensureTextCollation(value);
+ }
+
+ if (shouldUseSimpleCast) {
+ return this.ensureTextCollation(`${wrapped}::text`);
+ }
+
+ const coerced = this.coerceNonJsonExpressionToText(wrapped);
+ return this.ensureTextCollation(coerced);
+ }
+
+ private isHardTextExpression(value: string): boolean {
+ const trimmed = this.stripOuterParentheses(value);
+ if (this.isEmptyStringLiteral(trimmed)) {
+ return false;
+ }
+ if (/^'.+'$/.test(trimmed)) {
+ return true;
+ }
+ return this.getExpressionFieldType(value) === DbFieldType.Text;
+ }
+
+ private isDateLikeOperand(metadataIndex?: number): boolean {
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (!paramInfo?.hasMetadata) {
+ return false;
+ }
+ if (paramInfo.type === 'number') {
+ return false;
+ }
+ const hasFieldDateMetadata =
+ paramInfo.fieldDbType === DbFieldType.DateTime || paramInfo.fieldCellValueType === 'datetime';
+ const typeSaysDatetime =
+ isDatetimeLikeParam(paramInfo) && !paramInfo.fieldDbType && !paramInfo.fieldCellValueType;
+ const looksDatetime = hasFieldDateMetadata || typeSaysDatetime;
+
+ if (!looksDatetime) {
+ return false;
+ }
+
+ return !paramInfo.isJsonField && !paramInfo.isMultiValueField;
+ }
+
+ private buildDayInterval(expr: string, metadataIndex?: number): string {
+ const numeric = this.collapseNumeric(expr, metadataIndex);
+ return `(${numeric}) * INTERVAL '1 day'`;
+ }
+
+ private countANonNullExpression(value: string, metadataIndex?: number): string {
+ if (this.isTextLikeExpression(value, metadataIndex)) {
+ const normalizedComparable = this.normalizeBlankComparable(value, metadataIndex);
+ return `CASE WHEN ${value} IS NULL OR ${normalizedComparable} = '' THEN 0 ELSE 1 END`;
+ }
+
+ return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;
+ }
+
+ override add(left: string, right: string): string {
+ const leftIsDate = this.isDateLikeOperand(0);
+ const rightIsDate = this.isDateLikeOperand(1);
+
+ if (leftIsDate && !rightIsDate) {
+ return `(${this.castToTimestamp(left, 0)} + ${this.buildDayInterval(right, 1)})`;
+ }
+
+ if (!leftIsDate && rightIsDate) {
+ return `(${this.castToTimestamp(right, 1)} + ${this.buildDayInterval(left, 0)})`;
+ }
+
+ const l = this.collapseNumeric(left, 0);
+ const r = this.collapseNumeric(right, 1);
+ return `((${l}) + (${r}))`;
+ }
+
+ override subtract(left: string, right: string): string {
+ const leftIsDate = this.isDateLikeOperand(0);
+ const rightIsDate = this.isDateLikeOperand(1);
+
+ if (leftIsDate && !rightIsDate) {
+ return `(${this.castToTimestamp(left, 0)} - ${this.buildDayInterval(right, 1)})`;
+ }
+
+ if (leftIsDate && rightIsDate) {
+ return `(EXTRACT(EPOCH FROM ${this.castToTimestamp(left, 0)} - ${this.castToTimestamp(
+ right,
+ 1
+ )}) / 86400)`;
+ }
+
+ const l = this.collapseNumeric(left, 0);
+ const r = this.collapseNumeric(right, 1);
+ return `((${l}) - (${r}))`;
+ }
+
+ override multiply(left: string, right: string): string {
+ const l = this.collapseNumeric(left, 0);
+ const r = this.collapseNumeric(right, 1);
+ return `((${l}) * (${r}))`;
+ }
+
+ override unaryMinus(value: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ return `(-(${numericValue}))`;
+ }
+
+ override divide(left: string, right: string): string {
+ const numerator = this.collapseNumeric(left, 0);
+ const denominator = this.toNumericSafe(right, 1);
+ return `(CASE WHEN (${denominator}) IS NULL OR (${denominator}) = 0 THEN NULL ELSE (${numerator} / ${denominator}) END)`;
+ }
+
+ override modulo(left: string, right: string): string {
+ const dividend = this.collapseNumeric(left, 0);
+ const divisor = this.toNumericSafe(right, 1);
+ return `(CASE WHEN (${divisor}) IS NULL OR (${divisor}) = 0 THEN NULL ELSE MOD((${dividend})::numeric, (${divisor})::numeric)::double precision END)`;
+ }
+
+ private isBooleanLikeExpression(value: string, metadataIndex?: number): boolean {
+ const trimmed = this.stripOuterParentheses(value);
+ if (/^(true|false)$/i.test(trimmed)) {
+ return true;
+ }
+
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (paramInfo?.hasMetadata && isBooleanLikeParam(paramInfo)) {
+ return true;
+ }
+
+ return this.getExpressionFieldType(value) === DbFieldType.Boolean;
+ }
+
+ private normalizeBooleanCondition(condition: string, metadataIndex = 0): string {
+ const wrapped = `(${condition})`;
+ if (this.isBooleanLikeExpression(condition, metadataIndex)) {
+ return `COALESCE(${wrapped}::boolean, FALSE)`;
+ }
+
+ const paramInfo = this.getParamInfo(metadataIndex);
+ if (isTrustedNumeric(paramInfo)) {
+ const numericExpr = this.toNumericSafe(condition, metadataIndex);
+ return `(COALESCE(${numericExpr}, 0) <> 0)`;
+ }
+
+ const conditionType = `pg_typeof${wrapped}::text`;
+ const numericTypes = "('smallint','integer','bigint','numeric','double precision','real')";
+ const stringTypes = "('text','character varying','character','varchar','unknown')";
+ const wrappedText = `(${wrapped})::text`;
+ const booleanTruthyScore = `CASE WHEN LOWER(${wrappedText}) IN ('t','true','1') THEN 1 ELSE 0 END`;
+ const numericTruthyScore = `CASE WHEN ${wrappedText} ~ '^\\s*[+-]{0,1}0*(\\.0*){0,1}\\s*$' THEN 0 ELSE 1 END`;
+ const fallbackTruthyScore = `CASE
+ WHEN COALESCE(${wrappedText}, '') = '' THEN 0
+ WHEN LOWER(${wrappedText}) = 'null' THEN 0
+ ELSE 1
+ END`;
+
+ return `CASE
+ WHEN ${wrapped} IS NULL THEN 0
+ WHEN ${conditionType} = 'boolean' THEN ${booleanTruthyScore}
+ WHEN ${conditionType} IN ${numericTypes} THEN ${numericTruthyScore}
+ WHEN ${conditionType} IN ${stringTypes} THEN ${fallbackTruthyScore}
+ ELSE ${fallbackTruthyScore}
+ END = 1`;
+ }
+
+ // Numeric Functions
+ sum(params: string[]): string {
+ // Use addition instead of SUM() aggregation function for generated columns
+ const numericParams = params.map((param, index) => `(${this.collapseNumeric(param, index)})`);
+ return `(${numericParams.join(' + ')})`;
+ }
+
+ average(params: string[]): string {
+ // Use addition and division instead of AVG() aggregation function for generated columns
+ const numericParams = params.map((param, index) => `(${this.collapseNumeric(param, index)})`);
+ return `(${numericParams.join(' + ')}) / ${params.length}`;
+ }
+
+ max(params: string[]): string {
+ return `GREATEST(${this.joinParams(params)})`;
+ }
+
+ min(params: string[]): string {
+ return `LEAST(${this.joinParams(params)})`;
+ }
+
+ round(value: string, precision?: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ if (precision !== undefined) {
+ const numericPrecision = this.toNumericSafe(precision, 1);
+ return `ROUND(${numericValue}::numeric, ${numericPrecision}::integer)`;
+ }
+ return `ROUND(${numericValue})`;
+ }
+
+ roundUp(value: string, precision?: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ if (precision !== undefined) {
+ const numericPrecision = this.toNumericSafe(precision, 1);
+ const factor = `POWER(10, ${numericPrecision}::integer)`;
+ return `CEIL(${numericValue} * ${factor}) / ${factor}`;
+ }
+ return `CEIL(${numericValue})`;
+ }
+
+ roundDown(value: string, precision?: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ if (precision !== undefined) {
+ const numericPrecision = this.toNumericSafe(precision, 1);
+ const factor = `POWER(10, ${numericPrecision}::integer)`;
+ return `FLOOR(${numericValue} * ${factor}) / ${factor}`;
+ }
+ return `FLOOR(${numericValue})`;
+ }
+
+ ceiling(value: string): string {
+ return `CEIL(${this.toNumericSafe(value, 0)})`;
+ }
+
+ floor(value: string): string {
+ return `FLOOR(${this.toNumericSafe(value, 0)})`;
+ }
+
+ even(value: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ const intValue = `FLOOR(${numericValue})::integer`;
+ return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 0 THEN ${intValue} ELSE ${intValue} + 1 END`;
+ }
+
+ odd(value: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ const intValue = `FLOOR(${numericValue})::integer`;
+ return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 1 THEN ${intValue} ELSE ${intValue} + 1 END`;
+ }
+
+ int(value: string): string {
+ return `FLOOR(${this.toNumericSafe(value, 0)})`;
+ }
+
+ abs(value: string): string {
+ return `ABS(${this.toNumericSafe(value, 0)})`;
+ }
+
+ sqrt(value: string): string {
+ return `SQRT(${this.toNumericSafe(value, 0)})`;
+ }
+
+ power(base: string, exponent: string): string {
+ const baseValue = this.toNumericSafe(base, 0);
+ const exponentValue = this.toNumericSafe(exponent, 1);
+ return `POWER(${baseValue}, ${exponentValue})`;
+ }
+
+ exp(value: string): string {
+ return `EXP(${this.toNumericSafe(value, 0)})`;
+ }
+
+ log(value: string, base?: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ if (base !== undefined) {
+ const numericBase = this.toNumericSafe(base, 1);
+ const baseLog = `LN(${numericBase})`;
+ return `(LN(${numericValue}) / NULLIF(${baseLog}, 0))`;
+ }
+ return `LN(${numericValue})`;
+ }
+
+ mod(dividend: string, divisor: string): string {
+ const safeDividend = this.toNumericSafe(dividend, 0);
+ const safeDivisor = this.toNumericSafe(divisor, 1);
+ return `(CASE WHEN (${safeDivisor}) IS NULL OR (${safeDivisor}) = 0 THEN NULL ELSE MOD((${safeDividend})::numeric, (${safeDivisor})::numeric)::double precision END)`;
+ }
+
+ value(text: string): string {
+ return this.toNumericSafe(text, 0);
+ }
+
+ // Text Functions
+ concatenate(params: string[]): string {
+ // Use || operator instead of CONCAT for immutable generated columns
+ // CONCAT is stable, not immutable, which causes issues with generated columns
+ // Treat NULL values as empty strings to mirror client-side evaluation
+ const nullSafeParams = params.map((param) => `COALESCE(${param}::text, '')`);
+ return `(${this.joinParams(nullSafeParams, ' || ')})`;
+ }
+
+ // String concatenation for + operator (treats NULL as empty string)
+ // Use explicit text casting to handle mixed types and NULL values
+ stringConcat(left: string, right: string): string {
+ return `(COALESCE(${left}::text, '') || COALESCE(${right}::text, ''))`;
+ }
+
+ equal(left: string, right: string): string {
+ return this.buildBlankAwareComparison('=', left, right, { left: 0, right: 1 });
+ }
+
+ notEqual(left: string, right: string): string {
+ return this.buildBlankAwareComparison('<>', left, right, { left: 0, right: 1 });
+ }
+
+ greaterThan(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} > ${normalizedRight})`;
+ }
+
+ lessThan(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} < ${normalizedRight})`;
+ }
+
+ greaterThanOrEqual(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} >= ${normalizedRight})`;
+ }
+
+ lessThanOrEqual(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} <= ${normalizedRight})`;
+ }
+
+ // Override bitwiseAnd to handle PostgreSQL-specific type conversion
+ bitwiseAnd(left: string, right: string): string {
+ // Handle cases where operands might not be valid integers
+ // Use CASE to safely convert to integer, defaulting to 0 for invalid values
+ return `(
+ CASE
+ WHEN ${left}::text ~ '^-?[0-9]+$' AND ${left}::text != '' THEN ${left}::integer
+ ELSE 0
+ END &
+ CASE
+ WHEN ${right}::text ~ '^-?[0-9]+$' AND ${right}::text != '' THEN ${right}::integer
+ ELSE 0
+ END
+ )`;
+ }
+
+ find(searchText: string, withinText: string, startNum?: string): string {
+ const normalizedSearch = this.ensureTextCollation(searchText);
+ const normalizedWithin = this.ensureTextCollation(withinText);
+
+ if (startNum) {
+ return `POSITION(${normalizedSearch} IN SUBSTRING(${normalizedWithin} FROM ${startNum}::integer)) + ${startNum}::integer - 1`;
+ }
+ return `POSITION(${normalizedSearch} IN ${normalizedWithin})`;
+ }
+
+ search(searchText: string, withinText: string, startNum?: string): string {
+ const normalizedSearch = this.ensureTextCollation(searchText);
+ const normalizedWithin = this.ensureTextCollation(withinText);
+
+ // PostgreSQL doesn't have case-insensitive POSITION, so we use ILIKE with pattern matching
+ if (startNum) {
+ return `POSITION(UPPER(${normalizedSearch}) IN UPPER(SUBSTRING(${normalizedWithin} FROM ${startNum}::integer))) + ${startNum}::integer - 1`;
+ }
+ return `POSITION(UPPER(${normalizedSearch}) IN UPPER(${normalizedWithin}))`;
+ }
+
+ mid(text: string, startNum: string, numChars: string): string {
+ return `SUBSTRING((${text})::text FROM ${startNum}::integer FOR ${numChars}::integer)`;
+ }
+
+ left(text: string, numChars: string): string {
+ return `LEFT((${text})::text, ${numChars}::integer)`;
+ }
+
+ right(text: string, numChars: string): string {
+ return `RIGHT((${text})::text, ${numChars}::integer)`;
+ }
+
+ replace(oldText: string, startNum: string, numChars: string, newText: string): string {
+ const source = this.ensureTextCollation(oldText);
+ const replacement = this.ensureTextCollation(newText);
+ return `OVERLAY(${source} PLACING ${replacement} FROM ${startNum}::integer FOR ${numChars}::integer)`;
+ }
+
+ regexpReplace(text: string, pattern: string, replacement: string): string {
+ const source = this.ensureTextCollation(text);
+ const regex = this.ensureTextCollation(pattern);
+ const replacementText = this.ensureTextCollation(replacement);
+ return `REGEXP_REPLACE(${source}, ${regex}, ${replacementText}, 'g')`;
+ }
+
+ substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {
+ const source = this.ensureTextCollation(this.coerceToTextComparable(text, 0));
+ const search = this.ensureTextCollation(this.coerceToTextComparable(oldText, 1));
+ const replacement = this.ensureTextCollation(this.coerceToTextComparable(newText, 2));
+ if (instanceNum) {
+ // PostgreSQL doesn't have direct support for replacing specific instance
+ // This is a simplified implementation
+ return `REPLACE(${source}, ${search}, ${replacement})`;
+ }
+ return `REPLACE(${source}, ${search}, ${replacement})`;
+ }
+
+ lower(text: string): string {
+ const operand = this.coerceToTextComparable(text, 0);
+ return `LOWER(${operand})`;
+ }
+
+ upper(text: string): string {
+ const operand = this.coerceToTextComparable(text, 0);
+ return `UPPER(${operand})`;
+ }
+
+ rept(text: string, numTimes: string): string {
+ const operand = this.coerceToTextComparable(text, 0);
+ return `REPEAT(${operand}, ${numTimes}::integer)`;
+ }
+
+ trim(text: string): string {
+ const operand = this.coerceToTextComparable(text, 0);
+ return `TRIM(${operand})`;
+ }
+
+ len(text: string): string {
+ // Force text to prevent LENGTH() from receiving numeric/JSON operands (e.g., auto-number)
+ const operand = this.ensureTextCollation(this.coerceToTextComparable(text, 0));
+ return `LENGTH(${operand})`;
+ }
+
+ t(value: string): string {
+ return `CASE WHEN ${value} IS NULL THEN '' ELSE ${value}::text END`;
+ }
+
+ encodeUrlComponent(text: string): string {
+ // PostgreSQL doesn't have built-in URL encoding, this would need a custom function
+ return `encode(${text}::bytea, 'escape')`;
+ }
+
+ // DateTime Functions
+ now(): string {
+ // For generated columns, use the current timestamp at field creation time
+ if (this.isGeneratedColumnContext) {
+ const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
+ return `'${currentTimestamp}'::timestamp`;
+ }
+ return 'NOW()';
+ }
+
+ today(): string {
+ // For generated columns, use the current date at field creation time
+ if (this.isGeneratedColumnContext) {
+ const currentDate = new Date().toISOString().split('T')[0];
+ return `'${currentDate}'::date`;
+ }
+ return 'CURRENT_DATE';
+ }
+
+ private normalizeIntervalUnit(
+ unitLiteral: string,
+ options?: { treatQuarterAsMonth?: boolean }
+ ): {
+ unit:
+ | 'millisecond'
+ | 'second'
+ | 'minute'
+ | 'hour'
+ | 'day'
+ | 'week'
+ | 'month'
+ | 'quarter'
+ | 'year';
+ factor: number;
+ } {
+ const normalized = unitLiteral.trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return { unit: 'millisecond', factor: 1 };
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return { unit: 'second', factor: 1 };
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return { unit: 'minute', factor: 1 };
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return { unit: 'hour', factor: 1 };
+ case 'week':
+ case 'weeks':
+ return { unit: 'week', factor: 1 };
+ case 'month':
+ case 'months':
+ return { unit: 'month', factor: 1 };
+ case 'quarter':
+ case 'quarters':
+ if (options?.treatQuarterAsMonth === false) {
+ return { unit: 'quarter', factor: 1 };
+ }
+ return { unit: 'month', factor: 3 };
+ case 'year':
+ case 'years':
+ return { unit: 'year', factor: 1 };
+ case 'day':
+ case 'days':
+ default:
+ return { unit: 'day', factor: 1 };
+ }
+ }
+
+ private normalizeDiffUnit(
+ unitLiteral: string
+ ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {
+ const normalized = unitLiteral.trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return 'millisecond';
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return 'second';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return 'minute';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return 'hour';
+ case 'week':
+ case 'weeks':
+ return 'week';
+ case 'month':
+ case 'months':
+ return 'month';
+ case 'quarter':
+ case 'quarters':
+ return 'quarter';
+ case 'year':
+ case 'years':
+ return 'year';
+ default:
+ return 'day';
+ }
+ }
+
+ private normalizeTruncateUnit(
+ unitLiteral: string
+ ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {
+ const normalized = unitLiteral.trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return 'millisecond';
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return 'second';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return 'minute';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return 'hour';
+ case 'week':
+ case 'weeks':
+ return 'week';
+ case 'month':
+ case 'months':
+ return 'month';
+ case 'quarter':
+ case 'quarters':
+ return 'quarter';
+ case 'year':
+ case 'years':
+ return 'year';
+ case 'day':
+ case 'days':
+ default:
+ return 'day';
+ }
+ }
+
+ dateAdd(date: string, count: string, unit: string): string {
+ const { unit: cleanUnit, factor } = this.normalizeIntervalUnit(unit.replace(/^'|'$/g, ''));
+ const numericCount = this.toNumericSafe(count, 1);
+ const scaledCount = factor === 1 ? `(${numericCount})` : `(${numericCount}) * ${factor}`;
+ const timestampExpr = this.castToTimestamp(date, 0);
+ if (cleanUnit === 'quarter') {
+ return `${timestampExpr} + (${scaledCount}) * INTERVAL '1 month'`;
+ }
+ return `${timestampExpr} + (${scaledCount}) * INTERVAL '1 ${cleanUnit}'`;
+ }
+
+ datestr(date: string): string {
+ return `${this.castToTimestamp(date, 0)}::date::text`;
+ }
+
+ private buildMonthDiff(startDate: string, endDate: string): string {
+ const startExpr = this.castToTimestamp(startDate, 0);
+ const endExpr = this.castToTimestamp(endDate, 1);
+ const startYear = `EXTRACT(YEAR FROM ${startExpr})`;
+ const endYear = `EXTRACT(YEAR FROM ${endExpr})`;
+ const startMonth = `EXTRACT(MONTH FROM ${startExpr})`;
+ const endMonth = `EXTRACT(MONTH FROM ${endExpr})`;
+ const startDay = `EXTRACT(DAY FROM ${startExpr})`;
+ const endDay = `EXTRACT(DAY FROM ${endExpr})`;
+ const startLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${startExpr}) + INTERVAL '1 month - 1 day'))`;
+ const endLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${endExpr}) + INTERVAL '1 month - 1 day'))`;
+
+ const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;
+ const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;
+ const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;
+
+ return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;
+ }
+
+ datetimeDiff(startDate: string, endDate: string, unit: string): string {
+ const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));
+ const startExpr = this.castToTimestamp(startDate, 0);
+ const endExpr = this.castToTimestamp(endDate, 1);
+ const diffSeconds = `EXTRACT(EPOCH FROM ${startExpr} - ${endExpr})`;
+ switch (diffUnit) {
+ case 'millisecond':
+ return `(${diffSeconds}) * 1000`;
+ case 'second':
+ return `(${diffSeconds})`;
+ case 'minute':
+ return `(${diffSeconds}) / 60`;
+ case 'hour':
+ return `(${diffSeconds}) / 3600`;
+ case 'week':
+ return `(${diffSeconds}) / (86400 * 7)`;
+ case 'month':
+ return this.buildMonthDiff(startDate, endDate);
+ case 'quarter':
+ return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;
+ case 'year': {
+ const monthDiff = this.buildMonthDiff(startDate, endDate);
+ return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;
+ }
+ case 'day':
+ default:
+ return `(${diffSeconds}) / 86400`;
+ }
+ }
+
+ datetimeFormat(date: string, format: string): string {
+ return buildDatetimeFormatSql(this.castToTimestamp(date, 0), format);
+ }
+
+ datetimeParse(dateString: string, format?: string): string {
+ const valueExpr = `(${dateString})`;
+ const trustedDatetimeInput = this.hasTrustedDatetimeInput(0);
+
+ if (format == null) {
+ return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);
+ }
+ const trimmedFormat = format.trim();
+ if (!trimmedFormat || trimmedFormat === 'undefined' || trimmedFormat.toLowerCase() === 'null') {
+ return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);
+ }
+ if (trustedDatetimeInput) {
+ const localTimestampExpr = this.castToTimestamp(valueExpr, 0);
+ const formattedExpr = buildDatetimeFormatSql(localTimestampExpr, trimmedFormat);
+ return this.parseDatetimeParseWithFormat(formattedExpr, trimmedFormat);
+ }
+
+ return this.parseDatetimeParseWithFormat(`${valueExpr}::text`, trimmedFormat, valueExpr);
+ }
+
+ day(date: string): string {
+ return `EXTRACT(DAY FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {
+ const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));
+ const diffSeconds = `EXTRACT(EPOCH FROM ${nowExpr} - ${dateExpr})`;
+ const diffMonths = `EXTRACT(MONTH FROM AGE(${nowExpr}, ${dateExpr})) + EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr})) * 12`;
+ const diffYears = `EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr}))`;
+ switch (diffUnit) {
+ case 'millisecond':
+ return `(${diffSeconds}) * 1000`;
+ case 'second':
+ return `(${diffSeconds})`;
+ case 'minute':
+ return `(${diffSeconds}) / 60`;
+ case 'hour':
+ return `(${diffSeconds}) / 3600`;
+ case 'week':
+ return `(${diffSeconds}) / (86400 * 7)`;
+ case 'month':
+ return diffMonths;
+ case 'quarter':
+ return `(${diffMonths}) / 3.0`;
+ case 'year':
+ return diffYears;
+ case 'day':
+ default:
+ return `(${diffSeconds}) / 86400`;
+ }
+ }
+
+ fromNow(date: string, unit = 'day'): string {
+ // For generated columns, use the current timestamp at field creation time
+ const dateExpr = this.castToTimestamp(date, 0);
+ if (this.isGeneratedColumnContext) {
+ const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
+ return this.buildNowDiffByUnit(`'${currentTimestamp}'::timestamp`, dateExpr, unit);
+ }
+ return this.buildNowDiffByUnit('NOW()', dateExpr, unit);
+ }
+
+ hour(date: string): string {
+ return `EXTRACT(HOUR FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ isAfter(date1: string, date2: string): string {
+ return `${this.castToTimestamp(date1, 0)} > ${this.castToTimestamp(date2, 1)}`;
+ }
+
+ isBefore(date1: string, date2: string): string {
+ return `${this.castToTimestamp(date1, 0)} < ${this.castToTimestamp(date2, 1)}`;
+ }
+
+ isSame(date1: string, date2: string, unit?: string): string {
+ if (unit) {
+ const trimmed = unit.trim();
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
+ const literal = trimmed.slice(1, -1);
+ const normalized = this.normalizeTruncateUnit(literal);
+ const safeUnit = normalized.replace(/'/g, "''");
+ return `DATE_TRUNC('${safeUnit}', ${this.castToTimestamp(
+ date1,
+ 0
+ )}) = DATE_TRUNC('${safeUnit}', ${this.castToTimestamp(date2, 1)})`;
+ }
+ return `DATE_TRUNC(${unit}, ${this.castToTimestamp(date1, 0)}) = DATE_TRUNC(${unit}, ${this.castToTimestamp(date2, 1)})`;
+ }
+ return `${this.castToTimestamp(date1, 0)} = ${this.castToTimestamp(date2, 1)}`;
+ }
+
+ lastModifiedTime(): string {
+ // This would typically reference a system column
+ return '"__last_modified_time"';
+ }
+
+ minute(date: string): string {
+ return `EXTRACT(MINUTE FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ month(date: string): string {
+ return `EXTRACT(MONTH FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ second(date: string): string {
+ return `EXTRACT(SECOND FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ timestr(date: string): string {
+ return `(${this.castToTimestamp(date, 0)})::time::text`;
+ }
+
+ toNow(date: string, unit = 'day'): string {
+ return this.fromNow(date, unit);
+ }
+
+ weekNum(date: string): string {
+ return `EXTRACT(WEEK FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ weekday(date: string, _startDayOfWeek?: string): string {
+ return `EXTRACT(DOW FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ workday(startDate: string, days: string, _holidayStr?: string): string {
+ if (!this.isDateLikeOperand(0)) {
+ return 'NULL';
+ }
+ // Simplified implementation - doesn't account for weekends/holidays
+ return `${this.castToTimestamp(startDate, 0)}::date + INTERVAL '1 day' * ${days}::integer`;
+ }
+
+ workdayDiff(startDate: string, endDate: string): string {
+ if (!this.isDateLikeOperand(0) || !this.isDateLikeOperand(1)) {
+ return 'NULL';
+ }
+ // Simplified implementation - doesn't account for weekends/holidays
+ return `${this.castToTimestamp(endDate, 1)}::date - ${this.castToTimestamp(startDate, 0)}::date`;
+ }
+
+ year(date: string): string {
+ return `EXTRACT(YEAR FROM ${this.castToTimestamp(date, 0)})`;
+ }
+
+ createdTime(): string {
+ // This would typically reference a system column
+ return '"__created_time"';
+ }
+
+ // Logical Functions
+ if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
+ const booleanCondition = this.normalizeBooleanCondition(condition, 0);
+ const trueIsBlank = this.isEmptyStringLiteral(valueIfTrue) || this.isNullLiteral(valueIfTrue);
+ const falseIsBlank =
+ this.isEmptyStringLiteral(valueIfFalse) || this.isNullLiteral(valueIfFalse);
+ const resultIsDatetime = this.isDateLikeOperand(1) || this.isDateLikeOperand(2);
+ if (resultIsDatetime) {
+ const trueBranch = trueIsBlank ? 'NULL' : this.castToTimestamp(valueIfTrue, 1);
+ const falseBranch = falseIsBlank ? 'NULL' : this.castToTimestamp(valueIfFalse, 2);
+ return `CASE WHEN (${booleanCondition}) THEN ${trueBranch} ELSE ${falseBranch} END`;
+ }
+ const trueIsText = this.isTextLikeExpression(valueIfTrue, 1);
+ const falseIsText = this.isTextLikeExpression(valueIfFalse, 2);
+ const trueIsHardText = this.isHardTextExpression(valueIfTrue);
+ const falseIsHardText = this.isHardTextExpression(valueIfFalse);
+ const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);
+ const numericWithBlank =
+ (trueIsBlank && !falseIsHardText && !falseIsText) ||
+ (falseIsBlank && !trueIsHardText && !trueIsText);
+ if (numericWithBlank) {
+ const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);
+ const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
+ return `CASE WHEN (${booleanCondition}) THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
+ }
+ const hasNumericBranch =
+ this.isNumericLikeExpression(valueIfTrue, 1) || this.isNumericLikeExpression(valueIfFalse, 2);
+ if (hasNumericBranch && !hasTextBranch) {
+ const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);
+ const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
+ return `CASE WHEN (${booleanCondition}) THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
+ }
+ const blankPresent = trueIsBlank || falseIsBlank;
+ const hasTextAfterBlank = blankPresent ? false : hasTextBranch;
+ const normalizeBlankAsNull = !hasTextAfterBlank && blankPresent;
+ const trueBranch = hasTextAfterBlank
+ ? this.coerceToTextComparable(valueIfTrue, 1)
+ : trueIsBlank && normalizeBlankAsNull
+ ? 'NULL'
+ : valueIfTrue;
+ const falseBranch = hasTextAfterBlank
+ ? this.coerceToTextComparable(valueIfFalse, 2)
+ : falseIsBlank && normalizeBlankAsNull
+ ? 'NULL'
+ : valueIfFalse;
+ return `CASE WHEN (${booleanCondition}) THEN ${trueBranch} ELSE ${falseBranch} END`;
+ }
+
+ and(params: string[]): string {
+ return `(${this.joinParams(params, ' AND ')})`;
+ }
+
+ or(params: string[]): string {
+ return `(${this.joinParams(params, ' OR ')})`;
+ }
+
+ not(value: string): string {
+ return `NOT (${value})`;
+ }
+
+ xor(params: string[]): string {
+ // PostgreSQL doesn't have built-in XOR for multiple values
+ // This is a simplified implementation for two values
+ if (params.length === 2) {
+ return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;
+ }
+ // For multiple values, we need a more complex implementation
+ return `(${this.joinParams(
+ params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`),
+ ' + '
+ )}) % 2 = 1`;
+ }
+
+ blank(): string {
+ return 'NULL';
+ }
+
+ error(_message: string): string {
+ // ERROR function in PostgreSQL generated columns should return NULL
+ // since we can't throw actual errors in generated columns
+ return 'NULL';
+ }
+
+ isError(value: string): string {
+ // PostgreSQL doesn't have a direct ISERROR function
+ // This would need custom error handling logic
+ return `CASE WHEN ${value} IS NULL THEN TRUE ELSE FALSE END`;
+ }
+
+ switch(
+ expression: string,
+ cases: Array<{ case: string; result: string }>,
+ defaultResult?: string
+ ): string {
+ const hasTextResult =
+ cases.some((c) => this.isTextLikeExpression(c.result)) ||
+ (defaultResult ? this.isTextLikeExpression(defaultResult) : false);
+
+ const normalizeResult = (value: string) =>
+ hasTextResult ? this.coerceToTextComparable(value) : value;
+
+ const normalizeCaseValue = (value: string) =>
+ hasTextResult ? this.coerceToTextComparable(value) : value;
+
+ const baseExpr = hasTextResult ? this.coerceToTextComparable(expression, 0) : expression;
+
+ let caseStatement = `CASE ${baseExpr}`;
+
+ for (const caseItem of cases) {
+ caseStatement += ` WHEN ${normalizeCaseValue(caseItem.case)} THEN ${normalizeResult(
+ caseItem.result
+ )}`;
+ }
+
+ if (defaultResult) {
+ caseStatement += ` ELSE ${normalizeResult(defaultResult)}`;
+ }
+
+ caseStatement += ' END';
+ return caseStatement;
+ }
+
+ // Array Functions
+ count(params: string[]): string {
+ // Count non-null values
+ return `(${params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 ELSE 0 END`).join(' + ')})`;
+ }
+
+ countA(params: string[]): string {
+ // Count non-empty values (including zeros)
+ const blankAwareChecks = params.map((p, index) => this.countANonNullExpression(p, index));
+ return `(${blankAwareChecks.join(' + ')})`;
+ }
+
+ countAll(value: string): string {
+ const paramInfo = this.getParamInfo(0);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ const normalized = `COALESCE(NULLIF((${value})::jsonb, 'null'::jsonb), '[]'::jsonb)`;
+ return `(CASE
+ WHEN jsonb_typeof(${normalized}) = 'array' THEN jsonb_array_length(${normalized})
+ ELSE 1
+ END)`;
+ }
+
+ // For single values, return 1 if not null, 0 if null.
+ return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;
+ }
+
+ private normalizeJsonbArray(array: string): string {
+ return `(CASE
+ WHEN ${array} IS NULL THEN '[]'::jsonb
+ WHEN jsonb_typeof(to_jsonb(${array})) = 'array' THEN to_jsonb(${array})
+ ELSE jsonb_build_array(to_jsonb(${array}))
+ END)`;
+ }
+
+ private buildJsonArrayUnion(
+ arrays: string[],
+ opts?: { filterNulls?: boolean; withOrdinal?: boolean }
+ ): string {
+ const selects = arrays.map((array, index) => {
+ const normalizedArray = this.normalizeJsonbArray(array);
+ const whereClause = opts?.filterNulls
+ ? " WHERE elem.value IS NOT NULL AND elem.value != 'null' AND elem.value != ''"
+ : '';
+ const ordinality = opts?.withOrdinal ? ', ord' : '';
+ return `SELECT elem.value, ${index} AS arg_index${ordinality}
+ FROM jsonb_array_elements_text(${normalizedArray}) WITH ORDINALITY AS elem(value, ord)${whereClause}`;
+ });
+
+ if (selects.length === 0) {
+ return 'SELECT NULL::text AS value, 0 AS arg_index, 0 AS ord WHERE FALSE';
+ }
+
+ return selects.join(' UNION ALL ');
+ }
+
+ arrayJoin(array: string, separator?: string): string {
+ const sep = separator || "', '";
+ return `ARRAY_TO_STRING(${array}, ${sep})`;
+ }
+
+ arrayUnique(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });
+ return `ARRAY(
+ SELECT DISTINCT ON (value) value
+ FROM (${unionQuery}) AS combined(value, arg_index, ord)
+ ORDER BY value, arg_index, ord
+ )`;
+ }
+
+ arrayFlatten(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });
+ return `ARRAY(
+ SELECT value
+ FROM (${unionQuery}) AS combined(value, arg_index, ord)
+ ORDER BY arg_index, ord
+ )`;
+ }
+
+ arrayCompact(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, { filterNulls: true, withOrdinal: true });
+ return `ARRAY(
+ SELECT value
+ FROM (${unionQuery}) AS combined(value, arg_index, ord)
+ ORDER BY arg_index, ord
+ )`;
+ }
+
+ // System Functions
+ recordId(): string {
+ // Reference the primary key column
+ return '"__id"';
+ }
+
+ autoNumber(): string {
+ // Reference the auto-increment column
+ return '"__auto_number"';
+ }
+
+ textAll(value: string): string {
+ // Convert array to text representation
+ return `ARRAY_TO_STRING(${value}, ', ')`;
+ }
+
+ // Override some base implementations for PostgreSQL-specific syntax
+ castToNumber(value: string): string {
+ return `${value}::numeric`;
+ }
+
+ castToString(value: string): string {
+ return `${value}::text`;
+ }
+
+ castToBoolean(value: string): string {
+ return `${value}::boolean`;
+ }
+
+ castToDate(value: string): string {
+ return `${value}::timestamp`;
+ }
+
+ // Field Reference - PostgreSQL uses double quotes for identifiers
+ fieldReference(_fieldId: string, columnName: string): string {
+ // For regular field references, return the column reference
+ // Note: Expansion is handled at the expression level, not at individual field reference level
+ return `"${columnName}"`;
+ }
+
+ protected escapeIdentifier(identifier: string): string {
+ return `"${identifier.replace(/"/g, '""')}"`;
+ }
+
+ private guardDefaultDatetimeParse(valueExpr: string): string {
+ const textExpr = `${valueExpr}::text`;
+ const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;
+ const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;
+ const pattern = getDefaultDatetimeParsePattern();
+ return `(CASE WHEN ${valueExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} ~ '${pattern}' THEN ${valueExpr} ELSE NULL END)`;
+ }
+
+ private parseDatetimeParseWithoutFormat(valueExpr: string): string {
+ const textExpr = `${valueExpr}::text`;
+ const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;
+ const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;
+ const pattern = getDefaultDatetimeParsePattern();
+ const hasClockTime = `(${sanitizedExpr} ~ '[ T][0-9]{1,2}:[0-9]{2}')`;
+ const hasExplicitTimeZone = `(${sanitizedExpr} ~* '(Z|[+-][0-9]{2}:[0-9]{2}|[+-][0-9]{4}|[+-][0-9]{2})$')`;
+ const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, "''");
+ const localTimestampExpr = `(${sanitizedExpr})::timestamp AT TIME ZONE '${safeTz}'`;
+ const explicitZoneExpr = `(${sanitizedExpr})::timestamptz`;
+
+ return `(CASE
+ WHEN ${valueExpr} IS NULL THEN NULL
+ WHEN ${sanitizedExpr} IS NULL THEN NULL
+ WHEN ${sanitizedExpr} ~ '${pattern}' THEN
+ (CASE
+ WHEN ${hasClockTime} AND NOT ${hasExplicitTimeZone} THEN ${localTimestampExpr}
+ ELSE ${explicitZoneExpr}
+ END)
+ ELSE NULL
+ END)`;
+ }
+
+ private parseDatetimeParseWithFormat(
+ textExpr: string,
+ formatExpr: string,
+ nullGuardExpr: string = textExpr
+ ): string {
+ const normalizedFormat = normalizeDatetimeFormatExpression(formatExpr);
+ const toTimestampExpr = `TO_TIMESTAMP(${textExpr}::text, ${normalizedFormat})`;
+ const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, "''");
+ const hasTimezoneToken = hasDatetimeTimezoneToken(formatExpr);
+ const parsedExpr =
+ hasTimezoneToken === false
+ ? `(${toTimestampExpr})::timestamp AT TIME ZONE '${safeTz}'`
+ : toTimestampExpr;
+ const guardPattern = buildDatetimeParseGuardRegex(formatExpr);
+ if (!guardPattern) {
+ return parsedExpr;
+ }
+ const escapedPattern = guardPattern.replace(/'/g, "''");
+ return `(CASE WHEN ${nullGuardExpr} IS NULL THEN NULL WHEN ${textExpr} = '' THEN NULL WHEN ${textExpr} ~ '${escapedPattern}' THEN ${parsedExpr} ELSE NULL END)`;
+ }
+ private castToTimestamp(date: string, metadataIndex?: number): string {
+ const isTimestampish = (expr: string): boolean => {
+ const trimmed = this.stripOuterParentheses(expr);
+ return (
+ /::timestamp(tz)?\b/i.test(trimmed) ||
+ /\bAT\s+TIME\s+ZONE\b/i.test(trimmed) ||
+ /^NOW\(\)/i.test(trimmed) ||
+ /^CURRENT_TIMESTAMP/i.test(trimmed)
+ );
+ };
+
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (paramInfo?.hasMetadata && paramInfo.type === 'number') {
+ return 'NULL::timestamp';
+ }
+ const looksDatetime =
+ paramInfo?.hasMetadata &&
+ (isDatetimeLikeParam(paramInfo) ||
+ paramInfo.fieldDbType === DbFieldType.DateTime ||
+ paramInfo.fieldCellValueType === 'datetime');
+
+ if (!looksDatetime && !isTimestampish(date)) {
+ return 'NULL::timestamp';
+ }
+
+ const valueExpr = `(${date})`;
+ const trustedInput =
+ (metadataIndex != null && this.hasTrustedDatetimeInput(metadataIndex)) ||
+ this.getExpressionFieldType(date) === DbFieldType.DateTime;
+
+ if (trustedInput) {
+ return `${valueExpr}::timestamp`;
+ }
+
+ const guarded = this.guardDefaultDatetimeParse(valueExpr);
+ return `${guarded}::timestamp`;
+ }
+
+ private hasTrustedDatetimeInput(index: number): boolean {
+ const paramInfo = this.getParamInfo(index);
+ if (!paramInfo.hasMetadata) {
+ return false;
+ }
+ if (!isDatetimeLikeParam(paramInfo)) {
+ return false;
+ }
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query-support-validator.sqlite.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query-support-validator.sqlite.ts
new file mode 100644
index 0000000000..6fab5cc9db
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query-support-validator.sqlite.ts
@@ -0,0 +1,513 @@
+import type {
+ IFormulaConversionContext,
+ IGeneratedColumnQuerySupportValidator,
+} from '../../../features/record/query-builder/sql-conversion.visitor';
+
+/**
+ * SQLite-specific implementation for validating generated column function support
+ * Returns true for functions that can be safely converted to SQLite SQL expressions
+ * suitable for use in generated columns, false for unsupported functions.
+ *
+ * SQLite has more limitations compared to PostgreSQL, especially for:
+ * - Complex array operations
+ * - Advanced text functions
+ * - Time-dependent functions
+ * - Functions requiring subqueries
+ */
+export class GeneratedColumnQuerySupportValidatorSqlite
+ implements IGeneratedColumnQuerySupportValidator
+{
+ protected context?: IFormulaConversionContext;
+
+ setContext(context: IFormulaConversionContext): void {
+ this.context = context;
+ }
+
+ setCallMetadata(): void {
+ // No-op for validator
+ }
+
+ // Numeric Functions - Most are supported
+ sum(_params: string[]): boolean {
+ // Use addition instead of SUM() aggregation function
+ return true;
+ }
+
+ average(_params: string[]): boolean {
+ // Use addition and division instead of AVG() aggregation function
+ return true;
+ }
+
+ max(_params: string[]): boolean {
+ return true;
+ }
+
+ min(_params: string[]): boolean {
+ return true;
+ }
+
+ round(_value: string, _precision?: string): boolean {
+ return true;
+ }
+
+ roundUp(_value: string, _precision?: string): boolean {
+ return true;
+ }
+
+ roundDown(_value: string, _precision?: string): boolean {
+ return true;
+ }
+
+ ceiling(_value: string): boolean {
+ // SQLite doesn't have CEIL function, but we can simulate it
+ return true;
+ }
+
+ floor(_value: string): boolean {
+ return true;
+ }
+
+ even(_value: string): boolean {
+ return true;
+ }
+
+ odd(_value: string): boolean {
+ return true;
+ }
+
+ int(_value: string): boolean {
+ return true;
+ }
+
+ abs(_value: string): boolean {
+ return true;
+ }
+
+ sqrt(_value: string): boolean {
+ // SQLite SQRT function implemented using mathematical approximation
+ return true;
+ }
+
+ power(_base: string, _exponent: string): boolean {
+ // SQLite POWER function implemented for common cases using multiplication
+ return true;
+ }
+
+ exp(_value: string): boolean {
+ // SQLite doesn't have EXP function built-in
+ return false;
+ }
+
+ log(_value: string, _base?: string): boolean {
+ // SQLite doesn't have LOG function built-in
+ return false;
+ }
+
+ mod(_dividend: string, _divisor: string): boolean {
+ return true;
+ }
+
+ value(_text: string): boolean {
+ return true;
+ }
+
+ // Text Functions - Most basic ones are supported
+ concatenate(_params: string[]): boolean {
+ return true;
+ }
+
+ stringConcat(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ find(_searchText: string, _withinText: string, _startNum?: string): boolean {
+ // SQLite has limited string search capabilities
+ return true;
+ }
+
+ search(_searchText: string, _withinText: string, _startNum?: string): boolean {
+ // Similar to find, basic support
+ return true;
+ }
+
+ mid(_text: string, _startNum: string, _numChars: string): boolean {
+ return true;
+ }
+
+ left(_text: string, _numChars: string): boolean {
+ return true;
+ }
+
+ right(_text: string, _numChars: string): boolean {
+ return true;
+ }
+
+ replace(_oldText: string, _startNum: string, _numChars: string, _newText: string): boolean {
+ return true;
+ }
+
+ regexpReplace(_text: string, _pattern: string, _replacement: string): boolean {
+ // SQLite has limited regex support
+ return false;
+ }
+
+ substitute(_text: string, _oldText: string, _newText: string, _instanceNum?: string): boolean {
+ return true;
+ }
+
+ lower(_text: string): boolean {
+ return true;
+ }
+
+ upper(_text: string): boolean {
+ return true;
+ }
+
+ rept(_text: string, _numTimes: string): boolean {
+ // SQLite doesn't have a built-in repeat function
+ return false;
+ }
+
+ trim(_text: string): boolean {
+ return true;
+ }
+
+ len(_text: string): boolean {
+ return true;
+ }
+
+ t(_value: string): boolean {
+ return true;
+ }
+
+ encodeUrlComponent(_text: string): boolean {
+ // SQLite doesn't have built-in URL encoding
+ return false;
+ }
+
+ // DateTime Functions - Limited support, some have limitations but are still usable
+ now(): boolean {
+ // now() is supported but results are fixed at creation time
+ return true;
+ }
+
+ today(): boolean {
+ // today() is supported but results are fixed at creation time
+ return true;
+ }
+
+ dateAdd(_date: string, _count: string, _unit: string): boolean {
+ // DATE_ADD relies on SQLite datetime helpers that are not immutable-safe for generated columns
+ return false;
+ }
+
+ datestr(_date: string): boolean {
+ return true;
+ }
+
+ datetimeDiff(_startDate: string, _endDate: string, _unit: string): boolean {
+ return true;
+ }
+
+ datetimeFormat(_date: string, _format: string): boolean {
+ return true;
+ }
+
+ datetimeParse(_dateString: string, _format?: string): boolean {
+ // SQLite has limited date parsing capabilities
+ return false;
+ }
+
+ day(_date: string): boolean {
+ // DAY with column references is not immutable in SQLite
+ return false;
+ }
+
+ fromNow(_date: string): boolean {
+ // fromNow results are unpredictable due to fixed creation time
+ return false;
+ }
+
+ hour(_date: string): boolean {
+ // HOUR with column references is not immutable in SQLite
+ return false;
+ }
+
+ isAfter(_date1: string, _date2: string): boolean {
+ return true;
+ }
+
+ isBefore(_date1: string, _date2: string): boolean {
+ return true;
+ }
+
+ isSame(_date1: string, _date2: string, _unit?: string): boolean {
+ return true;
+ }
+
+ lastModifiedTime(): boolean {
+ return false;
+ }
+
+ minute(_date: string): boolean {
+ // MINUTE with column references is not immutable in SQLite
+ return false;
+ }
+
+ month(_date: string): boolean {
+ // MONTH with column references is not immutable in SQLite
+ return false;
+ }
+
+ second(_date: string): boolean {
+ // SECOND with column references is not immutable in SQLite
+ return false;
+ }
+
+ timestr(_date: string): boolean {
+ return true;
+ }
+
+ toNow(_date: string): boolean {
+ // toNow results are unpredictable due to fixed creation time
+ return false;
+ }
+
+ weekNum(_date: string): boolean {
+ return true;
+ }
+
+ weekday(_date: string): boolean {
+ // WEEKDAY with column references is not immutable in SQLite
+ return false;
+ }
+
+ workday(_startDate: string, _days: string): boolean {
+ // Complex date calculations are limited in SQLite
+ return false;
+ }
+
+ workdayDiff(_startDate: string, _endDate: string): boolean {
+ // Complex date calculations are limited in SQLite
+ return false;
+ }
+
+ year(_date: string): boolean {
+ // YEAR with column references is not immutable in SQLite
+ return false;
+ }
+
+ createdTime(): boolean {
+ return false;
+ }
+
+ // Logical Functions - IF fallback to computed evaluation (not immutable-safe).
+ // Example: `IF({LinkField}, 1, 0)` needs to inspect JSON link arrays at runtime;
+ // SQLite generated columns cannot express that immutably, so we prevent GC usage.
+ if(_condition: string, _valueIfTrue: string, _valueIfFalse: string): boolean {
+ return false;
+ }
+
+ and(_params: string[]): boolean {
+ return true;
+ }
+
+ or(_params: string[]): boolean {
+ return true;
+ }
+
+ not(_value: string): boolean {
+ return true;
+ }
+
+ xor(_params: string[]): boolean {
+ return true;
+ }
+
+ blank(): boolean {
+ return true;
+ }
+
+ error(_message: string): boolean {
+ // Cannot throw errors in generated column definitions
+ return false;
+ }
+
+ isError(_value: string): boolean {
+ // Cannot detect runtime errors in generated columns
+ return false;
+ }
+
+ switch(
+ _expression: string,
+ _cases: Array<{ case: string; result: string }>,
+ _defaultResult?: string
+ ): boolean {
+ return true;
+ }
+
+ // Array Functions - Limited support due to SQLite constraints
+ count(_params: string[]): boolean {
+ return true;
+ }
+
+ countA(_params: string[]): boolean {
+ return true;
+ }
+
+ countAll(_value: string): boolean {
+ return true;
+ }
+
+ arrayJoin(_array: string, _separator?: string): boolean {
+ // Limited support, basic JSON array joining only
+ return false;
+ }
+
+ arrayUnique(_arrays: string[]): boolean {
+ // SQLite generated columns don't support complex operations for uniqueness
+ return false;
+ }
+
+ arrayFlatten(_arrays: string[]): boolean {
+ // SQLite generated columns don't support complex array flattening
+ return false;
+ }
+
+ arrayCompact(_arrays: string[]): boolean {
+ // SQLite generated columns don't support complex filtering without subqueries
+ return false;
+ }
+
+ // System Functions - Supported
+ recordId(): boolean {
+ // recordId is supported
+ return false;
+ }
+
+ autoNumber(): boolean {
+ return false;
+ }
+
+ textAll(_value: string): boolean {
+ // textAll with non-array types causes function mismatch in SQLite
+ return false;
+ }
+
+ // Binary Operations - All supported
+ add(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ subtract(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ multiply(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ divide(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ modulo(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ // Comparison Operations - All supported
+ equal(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ notEqual(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ greaterThan(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ lessThan(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ greaterThanOrEqual(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ lessThanOrEqual(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ // Logical Operations - All supported
+ logicalAnd(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ logicalOr(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ bitwiseAnd(_left: string, _right: string): boolean {
+ return true;
+ }
+
+ // Unary Operations - All supported
+ unaryMinus(_value: string): boolean {
+ return true;
+ }
+
+ // Field Reference - Supported
+ fieldReference(_fieldId: string, _columnName: string): boolean {
+ return true;
+ }
+
+ // Literals - All supported
+ stringLiteral(_value: string): boolean {
+ return true;
+ }
+
+ numberLiteral(_value: number): boolean {
+ return true;
+ }
+
+ booleanLiteral(_value: boolean): boolean {
+ return true;
+ }
+
+ nullLiteral(): boolean {
+ return true;
+ }
+
+ // Utility methods - All supported
+ castToNumber(_value: string): boolean {
+ return true;
+ }
+
+ castToString(_value: string): boolean {
+ return true;
+ }
+
+ castToBoolean(_value: string): boolean {
+ return true;
+ }
+
+ castToDate(_value: string): boolean {
+ return true;
+ }
+
+ // Handle null values and type checking - All supported
+ isNull(_value: string): boolean {
+ return true;
+ }
+
+ coalesce(_params: string[]): boolean {
+ return true;
+ }
+
+ // Parentheses for grouping - Supported
+ parentheses(_expression: string): boolean {
+ return true;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.spec.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.spec.ts
new file mode 100644
index 0000000000..d6f3722b12
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.spec.ts
@@ -0,0 +1,73 @@
+import { DbFieldType } from '@teable/core';
+import { describe, expect, it } from 'vitest';
+import { GeneratedColumnQuerySqlite } from './generated-column-query.sqlite';
+
+describe('GeneratedColumnQuerySqlite countAll', () => {
+ it('counts multi-value json field elements in COUNTALL', () => {
+ const query = new GeneratedColumnQuerySqlite();
+ query.setContext({} as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'string',
+ isFieldReference: true,
+ field: {
+ id: 'fldMulti',
+ isMultiple: true,
+ isLookup: false,
+ dbFieldName: '__owners',
+ dbFieldType: DbFieldType.Json,
+ cellValueType: 'string',
+ },
+ },
+ ] as unknown as never);
+
+ const sql = query.countAll('`__owners`');
+ expect(sql).toContain('json_array_length');
+ expect(sql).toContain("json_type(`__owners`) = 'array'");
+ });
+
+ it('keeps scalar COUNTALL behavior for non-json field', () => {
+ const query = new GeneratedColumnQuerySqlite();
+ query.setContext({} as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'number',
+ isFieldReference: true,
+ field: {
+ id: 'fldNumber',
+ isMultiple: false,
+ isLookup: false,
+ dbFieldName: '__number',
+ dbFieldType: DbFieldType.Real,
+ cellValueType: 'number',
+ },
+ },
+ ] as unknown as never);
+
+ expect(query.countAll('`__number`')).toBe('CASE WHEN `__number` IS NULL THEN 0 ELSE 1 END');
+ });
+});
+
+describe('GeneratedColumnQuerySqlite FROMNOW/TONOW', () => {
+ it('applies unit conversion for FROMNOW', () => {
+ const query = new GeneratedColumnQuerySqlite();
+ query.setContext({} as unknown as never);
+
+ const daySql = query.fromNow('date_col', "'day'");
+ const hourSql = query.fromNow('date_col', "'hour'");
+ const secondSql = query.fromNow('date_col', "'second'");
+
+ expect(daySql).toBe("(JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))");
+ expect(hourSql).toBe("((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0");
+ expect(secondSql).toBe("((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0 * 60 * 60");
+ });
+
+ it('keeps TONOW aligned with FROMNOW direction', () => {
+ const query = new GeneratedColumnQuerySqlite();
+ query.setContext({} as unknown as never);
+
+ const fromNowSql = query.fromNow('date_col', "'day'");
+ const toNowSql = query.toNow('date_col', "'day'");
+ expect(toNowSql).toBe(fromNowSql);
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.ts
new file mode 100644
index 0000000000..89d664ed3d
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/generated-column-query/sqlite/generated-column-query.sqlite.ts
@@ -0,0 +1,883 @@
+/* eslint-disable sonarjs/no-identical-functions */
+import { isTextLikeParam, resolveFormulaParamInfo } from '../../utils/formula-param-metadata.util';
+import { GeneratedColumnQueryAbstract } from '../generated-column-query.abstract';
+
+/**
+ * SQLite-specific implementation of generated column query functions
+ * Converts Teable formula functions to SQLite SQL expressions suitable
+ * for use in generated columns. All generated SQL must be immutable.
+ */
+export class GeneratedColumnQuerySqlite extends GeneratedColumnQueryAbstract {
+ private getParamInfo(index?: number) {
+ return resolveFormulaParamInfo(this.currentCallMetadata, index);
+ }
+
+ private isStringLiteral(value: string): boolean {
+ const trimmed = value.trim();
+ return /^'.*'$/.test(trimmed);
+ }
+
+ private isEmptyStringLiteral(value: string): boolean {
+ return value.trim() === "''";
+ }
+
+ private normalizeBlankComparable(value: string): string {
+ // Treat NULL and empty strings as empty text for comparison parity with interpreter
+ return `COALESCE(NULLIF(CAST((${value}) AS TEXT), ''), '')`;
+ }
+
+ private buildBlankAwareComparison(operator: '=' | '<>', left: string, right: string): string {
+ const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);
+ const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);
+ const leftInfo = this.getParamInfo(0);
+ const rightInfo = this.getParamInfo(1);
+ const textComparison =
+ leftIsEmptyLiteral ||
+ rightIsEmptyLiteral ||
+ this.isStringLiteral(left) ||
+ this.isStringLiteral(right) ||
+ isTextLikeParam(leftInfo) ||
+ isTextLikeParam(rightInfo);
+
+ if (!textComparison) {
+ return `(${left} ${operator} ${right})`;
+ }
+
+ const normalize = (value: string, isEmptyLiteral: boolean) =>
+ isEmptyLiteral ? "''" : this.normalizeBlankComparable(value);
+
+ return `(${normalize(left, leftIsEmptyLiteral)} ${operator} ${normalize(right, rightIsEmptyLiteral)})`;
+ }
+
+ // Numeric Functions
+ sum(params: string[]): string {
+ if (params.length === 0) {
+ return 'NULL';
+ }
+ if (params.length === 1) {
+ return `${params[0]}`;
+ }
+ // SQLite doesn't have SUM() for multiple values, use addition
+ return `(${this.joinParams(params, ' + ')})`;
+ }
+
+ average(params: string[]): string {
+ if (params.length === 0) {
+ return 'NULL';
+ }
+ if (params.length === 1) {
+ return `${params[0]}`;
+ }
+ // Calculate average as sum divided by count
+ return `((${this.joinParams(params, ' + ')}) / ${params.length})`;
+ }
+
+ max(params: string[]): string {
+ if (params.length === 0) {
+ return 'NULL';
+ }
+ if (params.length === 1) {
+ return `${params[0]}`;
+ }
+ // Use nested MAX functions for multiple values
+ return params.reduce((acc, param) => `MAX(${acc}, ${param})`);
+ }
+
+ min(params: string[]): string {
+ if (params.length === 0) {
+ return 'NULL';
+ }
+ if (params.length === 1) {
+ return `${params[0]}`;
+ }
+ // Use nested MIN functions for multiple values
+ return params.reduce((acc, param) => `MIN(${acc}, ${param})`);
+ }
+
+ round(value: string, precision?: string): string {
+ if (precision) {
+ return `ROUND(${value}, ${precision})`;
+ }
+ return `ROUND(${value})`;
+ }
+
+ roundUp(value: string, precision?: string): string {
+ if (precision) {
+ // Use manual power calculation for 10^precision (common cases)
+ const factor = `(
+ CASE
+ WHEN ${precision} = 0 THEN 1
+ WHEN ${precision} = 1 THEN 10
+ WHEN ${precision} = 2 THEN 100
+ WHEN ${precision} = 3 THEN 1000
+ WHEN ${precision} = 4 THEN 10000
+ ELSE 1
+ END
+ )`;
+ return `CAST(CEIL(${value} * ${factor}) / ${factor} AS REAL)`;
+ }
+ return `CAST(CEIL(${value}) AS INTEGER)`;
+ }
+
+ roundDown(value: string, precision?: string): string {
+ if (precision) {
+ // Use manual power calculation for 10^precision (common cases)
+ const factor = `(
+ CASE
+ WHEN ${precision} = 0 THEN 1
+ WHEN ${precision} = 1 THEN 10
+ WHEN ${precision} = 2 THEN 100
+ WHEN ${precision} = 3 THEN 1000
+ WHEN ${precision} = 4 THEN 10000
+ ELSE 1
+ END
+ )`;
+ return `CAST(FLOOR(${value} * ${factor}) / ${factor} AS REAL)`;
+ }
+ return `CAST(FLOOR(${value}) AS INTEGER)`;
+ }
+
+ ceiling(value: string): string {
+ return `CAST(CEIL(${value}) AS INTEGER)`;
+ }
+
+ floor(value: string): string {
+ return `CAST(FLOOR(${value}) AS INTEGER)`;
+ }
+
+ even(value: string): string {
+ return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 0 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;
+ }
+
+ odd(value: string): string {
+ return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 1 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;
+ }
+
+ int(value: string): string {
+ return `CAST(${value} AS INTEGER)`;
+ }
+
+ abs(value: string): string {
+ return `ABS(${value})`;
+ }
+
+ sqrt(value: string): string {
+ // SQLite doesn't have SQRT function, use Newton's method approximation
+ // One iteration of Newton's method: (x/2 + x/(x/2)) / 2
+ return `(
+ CASE
+ WHEN ${value} <= 0 THEN 0
+ ELSE (${value} / 2.0 + ${value} / (${value} / 2.0)) / 2.0
+ END
+ )`;
+ }
+
+ power(base: string, exponent: string): string {
+ // SQLite doesn't have POWER function, implement for common cases
+ return `(
+ CASE
+ WHEN ${exponent} = 0 THEN 1
+ WHEN ${exponent} = 1 THEN ${base}
+ WHEN ${exponent} = 2 THEN ${base} * ${base}
+ WHEN ${exponent} = 3 THEN ${base} * ${base} * ${base}
+ WHEN ${exponent} = 4 THEN ${base} * ${base} * ${base} * ${base}
+ WHEN ${exponent} = 0.5 THEN
+ -- Square root case using Newton's method
+ CASE
+ WHEN ${base} <= 0 THEN 0
+ ELSE (${base} / 2.0 + ${base} / (${base} / 2.0)) / 2.0
+ END
+ ELSE 1
+ END
+ )`;
+ }
+
+ exp(value: string): string {
+ return `EXP(${value})`;
+ }
+
+ log(value: string, base?: string): string {
+ if (base) {
+ return `(LOG(${value}) / LOG(${base}))`;
+ }
+ // SQLite LOG is base 10, but formula LOG should be natural log (base e)
+ return `LN(${value})`;
+ }
+
+ mod(dividend: string, divisor: string): string {
+ return `(${dividend} % ${divisor})`;
+ }
+
+ value(text: string): string {
+ return `CAST(${text} AS REAL)`;
+ }
+
+ // Text Functions
+ concatenate(params: string[]): string {
+ // Handle NULL values by converting them to empty strings for CONCATENATE function
+ // This mirrors the behavior of the formula evaluation engine
+ const nullSafeParams = params.map((param) => `COALESCE(${param}, '')`);
+ return `(${this.joinParams(nullSafeParams, ' || ')})`;
+ }
+
+ // String concatenation for + operator (treats NULL as empty string)
+ stringConcat(left: string, right: string): string {
+ return `(COALESCE(${left}, '') || COALESCE(${right}, ''))`;
+ }
+
+ equal(left: string, right: string): string {
+ return this.buildBlankAwareComparison('=', left, right);
+ }
+
+ notEqual(left: string, right: string): string {
+ return this.buildBlankAwareComparison('<>', left, right);
+ }
+
+ find(searchText: string, withinText: string, startNum?: string): string {
+ if (startNum) {
+ return `CASE WHEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) > 0 THEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) + ${startNum} - 1 ELSE 0 END`;
+ }
+ return `INSTR(${withinText}, ${searchText})`;
+ }
+
+ search(searchText: string, withinText: string, startNum?: string): string {
+ // SQLite INSTR is case-sensitive, so we use UPPER for case-insensitive search
+ if (startNum) {
+ return `CASE WHEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) > 0 THEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) + ${startNum} - 1 ELSE 0 END`;
+ }
+ return `INSTR(UPPER(${withinText}), UPPER(${searchText}))`;
+ }
+
+ mid(text: string, startNum: string, numChars: string): string {
+ return `SUBSTR(${text}, ${startNum}, ${numChars})`;
+ }
+
+ left(text: string, numChars: string): string {
+ return `SUBSTR(${text}, 1, ${numChars})`;
+ }
+
+ right(text: string, numChars: string): string {
+ return `SUBSTR(${text}, -${numChars})`;
+ }
+
+ replace(oldText: string, startNum: string, numChars: string, newText: string): string {
+ return `SUBSTR(${oldText}, 1, ${startNum} - 1) || ${newText} || SUBSTR(${oldText}, ${startNum} + ${numChars})`;
+ }
+
+ regexpReplace(text: string, pattern: string, replacement: string): string {
+ // SQLite doesn't have built-in regex replace, would need extension
+ return `REPLACE(${text}, ${pattern}, ${replacement})`;
+ }
+
+ substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {
+ // SQLite REPLACE replaces all instances, no direct support for specific instance
+ return `REPLACE(${text}, ${oldText}, ${newText})`;
+ }
+
+ lower(text: string): string {
+ return `LOWER(${text})`;
+ }
+
+ upper(text: string): string {
+ return `UPPER(${text})`;
+ }
+
+ rept(text: string, numTimes: string): string {
+ // SQLite doesn't have REPEAT function, need to use recursive CTE or custom function
+ return `REPLACE(HEX(ZEROBLOB(${numTimes})), '00', ${text})`;
+ }
+
+ trim(text: string): string {
+ return `TRIM(${text})`;
+ }
+
+ len(text: string): string {
+ return `LENGTH(${text})`;
+ }
+
+ t(value: string): string {
+ return `CASE
+ WHEN ${value} IS NULL THEN ''
+ WHEN ${value} = CAST(${value} AS INTEGER) THEN CAST(${value} AS INTEGER)
+ ELSE CAST(${value} AS TEXT)
+ END`;
+ }
+
+ encodeUrlComponent(text: string): string {
+ // SQLite doesn't have built-in URL encoding
+ return `${text}`;
+ }
+
+ // DateTime Functions
+ now(): string {
+ // For generated columns, use the current timestamp at field creation time
+ if (this.isGeneratedColumnContext) {
+ const currentTimestamp = new Date()
+ .toISOString()
+ .replace('T', ' ')
+ .replace('Z', '')
+ .replace(/\.\d{3}$/, '');
+ return `'${currentTimestamp}'`;
+ }
+ return "DATETIME('now')";
+ }
+
+ today(): string {
+ // For generated columns, use the current date at field creation time
+ if (this.isGeneratedColumnContext) {
+ const currentDate = new Date().toISOString().split('T')[0];
+ return `'${currentDate}'`;
+ }
+ return "DATE('now')";
+ }
+
+ private normalizeDateModifier(unitLiteral: string): {
+ unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'months' | 'years';
+ factor: number;
+ } {
+ const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return { unit: 'seconds', factor: 0.001 };
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return { unit: 'seconds', factor: 1 };
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return { unit: 'minutes', factor: 1 };
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return { unit: 'hours', factor: 1 };
+ case 'week':
+ case 'weeks':
+ return { unit: 'days', factor: 7 };
+ case 'month':
+ case 'months':
+ return { unit: 'months', factor: 1 };
+ case 'quarter':
+ case 'quarters':
+ return { unit: 'months', factor: 3 };
+ case 'year':
+ case 'years':
+ return { unit: 'years', factor: 1 };
+ case 'day':
+ case 'days':
+ default:
+ return { unit: 'days', factor: 1 };
+ }
+ }
+
+ private normalizeDiffUnit(
+ unitLiteral: string
+ ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {
+ const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return 'millisecond';
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return 'second';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return 'minute';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return 'hour';
+ case 'week':
+ case 'weeks':
+ return 'week';
+ case 'month':
+ case 'months':
+ return 'month';
+ case 'quarter':
+ case 'quarters':
+ return 'quarter';
+ case 'year':
+ case 'years':
+ return 'year';
+ default:
+ return 'day';
+ }
+ }
+
+ private normalizeTruncateFormat(unitLiteral: string): string {
+ const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return '%Y-%m-%d %H:%M:%S';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return '%Y-%m-%d %H:%M';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return '%Y-%m-%d %H';
+ case 'week':
+ case 'weeks':
+ return '%Y-%W';
+ case 'month':
+ case 'months':
+ return '%Y-%m';
+ case 'year':
+ case 'years':
+ return '%Y';
+ case 'day':
+ case 'days':
+ default:
+ return '%Y-%m-%d';
+ }
+ }
+
+ dateAdd(date: string, count: string, unit: string): string {
+ const { unit: cleanUnit, factor } = this.normalizeDateModifier(unit);
+ const scaledCount = factor === 1 ? `(${count})` : `(${count}) * ${factor}`;
+ return `DATETIME(${date}, (${scaledCount}) || ' ${cleanUnit}')`;
+ }
+
+ datestr(date: string): string {
+ return `DATE(${date})`;
+ }
+
+ private buildMonthDiff(startDate: string, endDate: string): string {
+ const startYear = `CAST(STRFTIME('%Y', ${startDate}) AS INTEGER)`;
+ const endYear = `CAST(STRFTIME('%Y', ${endDate}) AS INTEGER)`;
+ const startMonth = `CAST(STRFTIME('%m', ${startDate}) AS INTEGER)`;
+ const endMonth = `CAST(STRFTIME('%m', ${endDate}) AS INTEGER)`;
+ const startDay = `CAST(STRFTIME('%d', ${startDate}) AS INTEGER)`;
+ const endDay = `CAST(STRFTIME('%d', ${endDate}) AS INTEGER)`;
+ const startLastDay = `CAST(STRFTIME('%d', DATE(${startDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;
+ const endLastDay = `CAST(STRFTIME('%d', DATE(${endDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;
+
+ const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;
+ const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;
+ const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;
+
+ return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;
+ }
+
+ datetimeDiff(startDate: string, endDate: string, unit: string): string {
+ const baseDiffDays = `(JULIANDAY(${startDate}) - JULIANDAY(${endDate}))`;
+ switch (this.normalizeDiffUnit(unit)) {
+ case 'millisecond':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;
+ case 'second':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60`;
+ case 'minute':
+ return `(${baseDiffDays}) * 24.0 * 60`;
+ case 'hour':
+ return `(${baseDiffDays}) * 24.0`;
+ case 'week':
+ return `(${baseDiffDays}) / 7.0`;
+ case 'month':
+ return this.buildMonthDiff(startDate, endDate);
+ case 'quarter':
+ return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;
+ case 'year': {
+ const monthDiff = this.buildMonthDiff(startDate, endDate);
+ return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;
+ }
+ case 'day':
+ default:
+ return `${baseDiffDays}`;
+ }
+ }
+
+ datetimeFormat(date: string, format: string): string {
+ // Convert common format patterns to SQLite STRFTIME format
+ const cleanFormat = format.replace(/^'|'$/g, '');
+ const sqliteFormat = cleanFormat
+ .replace(/YYYY/g, '%Y')
+ .replace(/MM/g, '%m')
+ .replace(/DD/g, '%d')
+ .replace(/HH/g, '%H')
+ .replace(/mm/g, '%M')
+ .replace(/ss/g, '%S');
+
+ return `STRFTIME('${sqliteFormat}', ${date})`;
+ }
+
+ datetimeParse(dateString: string, _format?: string): string {
+ // SQLite doesn't have direct parsing with custom format
+ return `DATETIME(${dateString})`;
+ }
+
+ day(date: string): string {
+ return `CAST(STRFTIME('%d', ${date}) AS INTEGER)`;
+ }
+
+ private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {
+ const diffUnit = this.normalizeDiffUnit(unit);
+ const baseDiffDays = `(JULIANDAY(${nowExpr}) - JULIANDAY(${dateExpr}))`;
+ switch (diffUnit) {
+ case 'millisecond':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;
+ case 'second':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60`;
+ case 'minute':
+ return `(${baseDiffDays}) * 24.0 * 60`;
+ case 'hour':
+ return `(${baseDiffDays}) * 24.0`;
+ case 'week':
+ return `(${baseDiffDays}) / 7.0`;
+ case 'month':
+ return this.buildMonthDiff(nowExpr, dateExpr);
+ case 'quarter':
+ return `${this.buildMonthDiff(nowExpr, dateExpr)} / 3.0`;
+ case 'year': {
+ const monthDiff = this.buildMonthDiff(nowExpr, dateExpr);
+ return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;
+ }
+ case 'day':
+ default:
+ return `${baseDiffDays}`;
+ }
+ }
+
+ fromNow(date: string, unit = 'day'): string {
+ // For generated columns, use the current timestamp at field creation time
+ const dateExpr = `DATETIME(${date})`;
+ if (this.isGeneratedColumnContext) {
+ const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
+ return this.buildNowDiffByUnit(`'${currentTimestamp}'`, dateExpr, unit);
+ }
+ return this.buildNowDiffByUnit("'now'", dateExpr, unit);
+ }
+
+ hour(date: string): string {
+ return `CAST(STRFTIME('%H', ${date}) AS INTEGER)`;
+ }
+
+ isAfter(date1: string, date2: string): string {
+ return `DATETIME(${date1}) > DATETIME(${date2})`;
+ }
+
+ isBefore(date1: string, date2: string): string {
+ return `DATETIME(${date1}) < DATETIME(${date2})`;
+ }
+
+ isSame(date1: string, date2: string, unit?: string): string {
+ if (unit) {
+ const trimmed = unit.trim();
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
+ const format = this.normalizeTruncateFormat(trimmed.slice(1, -1));
+ return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;
+ }
+ const format = this.normalizeTruncateFormat(unit);
+ return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;
+ }
+ return `DATETIME(${date1}) = DATETIME(${date2})`;
+ }
+
+ lastModifiedTime(): string {
+ return '__last_modified_time';
+ }
+
+ minute(date: string): string {
+ return `CAST(STRFTIME('%M', ${date}) AS INTEGER)`;
+ }
+
+ month(date: string): string {
+ return `CAST(STRFTIME('%m', ${date}) AS INTEGER)`;
+ }
+
+ second(date: string): string {
+ return `CAST(STRFTIME('%S', ${date}) AS INTEGER)`;
+ }
+
+ timestr(date: string): string {
+ return `TIME(${date})`;
+ }
+
+ toNow(date: string, unit = 'day'): string {
+ return this.fromNow(date, unit);
+ }
+
+ weekNum(date: string): string {
+ return `CAST(STRFTIME('%W', ${date}) AS INTEGER)`;
+ }
+
+ weekday(date: string, _startDayOfWeek?: string): string {
+ // Convert SQLite's 0-based weekday (0=Sunday) to 1-based (1=Sunday)
+ return `(CAST(STRFTIME('%w', ${date}) AS INTEGER) + 1)`;
+ }
+
+ workday(startDate: string, days: string, _holidayStr?: string): string {
+ return `DATE(${startDate}, '+' || ${days} || ' days')`;
+ }
+
+ workdayDiff(startDate: string, endDate: string): string {
+ return `CAST(JULIANDAY(${endDate}) - JULIANDAY(${startDate}) AS INTEGER)`;
+ }
+
+ year(date: string): string {
+ return `CAST(STRFTIME('%Y', ${date}) AS INTEGER)`;
+ }
+
+ createdTime(): string {
+ return '__created_time';
+ }
+
+ private normalizeBooleanCondition(condition: string): string {
+ const wrapped = `(${condition})`;
+ const valueType = `TYPEOF${wrapped}`;
+ return `CASE
+ WHEN ${wrapped} IS NULL THEN 0
+ WHEN ${valueType} = 'integer' OR ${valueType} = 'real' THEN (${wrapped}) != 0
+ WHEN ${valueType} = 'text' THEN (${wrapped} != '' AND LOWER(${wrapped}) != 'null')
+ ELSE (${wrapped}) IS NOT NULL AND ${wrapped} != 'null'
+ END`;
+ }
+
+ // Logical Functions
+ if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
+ const booleanCondition = this.normalizeBooleanCondition(condition);
+ return `CASE WHEN (${booleanCondition}) THEN ${valueIfTrue} ELSE ${valueIfFalse} END`;
+ }
+
+ and(params: string[]): string {
+ return `(${this.joinParams(params, ' AND ')})`;
+ }
+
+ or(params: string[]): string {
+ return `(${this.joinParams(params, ' OR ')})`;
+ }
+
+ not(value: string): string {
+ return `NOT (${value})`;
+ }
+
+ xor(params: string[]): string {
+ // SQLite doesn't have built-in XOR for multiple values
+ if (params.length === 2) {
+ return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;
+ }
+ // For multiple values, count true values and check if odd
+ return `(${this.joinParams(
+ params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`),
+ ' + '
+ )}) % 2 = 1`;
+ }
+
+ blank(): string {
+ return 'NULL';
+ }
+
+ error(_message: string): string {
+ // ERROR function in SQLite generated columns should return NULL
+ // since we can't throw actual errors in generated columns
+ return 'NULL';
+ }
+
+ isError(value: string): string {
+ // SQLite doesn't have a direct ISERROR function
+ return `CASE WHEN ${value} IS NULL THEN 1 ELSE 0 END`;
+ }
+
+ switch(
+ expression: string,
+ cases: Array<{ case: string; result: string }>,
+ defaultResult?: string
+ ): string {
+ let caseStatement = 'CASE';
+
+ for (const caseItem of cases) {
+ caseStatement += ` WHEN ${expression} = ${caseItem.case} THEN ${caseItem.result}`;
+ }
+
+ if (defaultResult) {
+ caseStatement += ` ELSE ${defaultResult}`;
+ }
+
+ caseStatement += ' END';
+ return caseStatement;
+ }
+
+ // Array Functions
+ count(params: string[]): string {
+ // Count non-null values
+ return `(${params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 ELSE 0 END`).join(' + ')})`;
+ }
+
+ countA(params: string[]): string {
+ // Count non-empty values (excluding empty strings)
+ return `(${params.map((p) => `CASE WHEN ${p} IS NOT NULL AND ${p} <> '' THEN 1 ELSE 0 END`).join(' + ')})`;
+ }
+
+ countAll(value: string): string {
+ const paramInfo = this.getParamInfo(0);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ return `CASE
+ WHEN ${value} IS NULL THEN 0
+ WHEN json_valid(${value}) AND json_type(${value}) = 'array' THEN COALESCE(json_array_length(${value}), 0)
+ WHEN json_valid(${value}) AND json_type(${value}) = 'null' THEN 0
+ ELSE 1
+ END`;
+ }
+
+ // For single values, return 1 if not null, 0 if null.
+ return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;
+ }
+
+ private buildJsonArrayUnion(
+ arrays: string[],
+ opts?: { filterNulls?: boolean; withOrdinal?: boolean }
+ ): string {
+ const selects = arrays.map((array, index) => {
+ const base = `SELECT value, ${index} AS arg_index, CAST(key AS INTEGER) AS ord FROM json_each(COALESCE(${array}, '[]'))`;
+ const whereClause = opts?.filterNulls
+ ? " WHERE value IS NOT NULL AND value != 'null' AND value != ''"
+ : '';
+ return `${base}${whereClause}`;
+ });
+
+ if (selects.length === 0) {
+ return 'SELECT NULL AS value, 0 AS arg_index, 0 AS ord WHERE 0';
+ }
+
+ return selects.join(' UNION ALL ');
+ }
+
+ arrayJoin(array: string, separator?: string): string {
+ // SQLite generated columns don't support subqueries, so we'll use simple string manipulation
+ // This assumes arrays are stored as JSON strings like ["a","b","c"] or ["a", "b", "c"]
+ const sep = separator ? this.stringLiteral(separator) : this.stringLiteral(', ');
+ return `(
+ CASE
+ WHEN json_valid(${array}) AND json_type(${array}) = 'array' THEN
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(${array}, '[', ''), ']', ''), '"', ''), ', ', ','), ',', ${sep})
+ WHEN ${array} IS NOT NULL THEN CAST(${array} AS TEXT)
+ ELSE NULL
+ END
+ )`;
+ }
+
+ arrayUnique(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true, filterNulls: true });
+ return `COALESCE(
+ '[' || (
+ SELECT GROUP_CONCAT(json_quote(value))
+ FROM (
+ SELECT value, ROW_NUMBER() OVER (PARTITION BY value ORDER BY arg_index, ord) AS rn, arg_index, ord
+ FROM (${unionQuery}) AS combined
+ )
+ WHERE rn = 1
+ ORDER BY arg_index, ord
+ ) || ']',
+ '[]'
+ )`;
+ }
+
+ arrayFlatten(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });
+ return `COALESCE(
+ '[' || (
+ SELECT GROUP_CONCAT(json_quote(value))
+ FROM (${unionQuery}) AS combined
+ ORDER BY arg_index, ord
+ ) || ']',
+ '[]'
+ )`;
+ }
+
+ arrayCompact(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, {
+ filterNulls: true,
+ withOrdinal: true,
+ });
+ return `COALESCE(
+ '[' || (
+ SELECT GROUP_CONCAT(json_quote(value))
+ FROM (${unionQuery}) AS combined
+ ORDER BY arg_index, ord
+ ) || ']',
+ '[]'
+ )`;
+ }
+
+ // System Functions
+ recordId(): string {
+ return '__id';
+ }
+
+ autoNumber(): string {
+ return '__auto_number';
+ }
+
+ textAll(value: string): string {
+ // Use same logic as t() function to handle integer formatting
+ return `CASE
+ WHEN ${value} = CAST(${value} AS INTEGER) THEN CAST(${value} AS INTEGER)
+ ELSE CAST(${value} AS TEXT)
+ END`;
+ }
+
+ // Field Reference - SQLite uses backticks for identifiers
+ fieldReference(_fieldId: string, columnName: string): string {
+ // For regular field references, return the column reference
+ // Note: Expansion is handled at the expression level, not at individual field reference level
+ return `\`${columnName}\``;
+ }
+
+ // Override some base implementations for SQLite-specific syntax
+ castToNumber(value: string): string {
+ return `CAST(${value} AS REAL)`;
+ }
+
+ castToString(value: string): string {
+ return `CAST(${value} AS TEXT)`;
+ }
+
+ castToBoolean(value: string): string {
+ return `CAST(${value} AS INTEGER)`;
+ }
+
+ castToDate(value: string): string {
+ return `DATETIME(${value})`;
+ }
+
+ // SQLite uses square brackets for identifiers with special characters
+ protected escapeIdentifier(identifier: string): string {
+ return `[${identifier.replace(/\]/g, ']]')}]`;
+ }
+
+ // Override binary operations to handle SQLite-specific behavior
+ modulo(left: string, right: string): string {
+ return `(${left} % ${right})`;
+ }
+
+ // SQLite uses different boolean literals
+ booleanLiteral(value: boolean): string {
+ return value ? '1' : '0';
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/group-query/format-string.ts b/apps/nestjs-backend/src/db-provider/group-query/format-string.ts
new file mode 100644
index 0000000000..41787aa04b
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/group-query/format-string.ts
@@ -0,0 +1,28 @@
+import { DateFormattingPreset, TimeFormatting } from '@teable/core';
+
+export const getPostgresDateTimeFormatString = (
+ date: DateFormattingPreset,
+ time: TimeFormatting
+) => {
+ switch (date) {
+ case DateFormattingPreset.Y:
+ return 'YYYY';
+ case DateFormattingPreset.M:
+ case DateFormattingPreset.YM:
+ return 'YYYY-MM';
+ default:
+ return time !== TimeFormatting.None ? 'YYYY-MM-DD HH24:MI' : 'YYYY-MM-DD';
+ }
+};
+
+export const getSqliteDateTimeFormatString = (date: DateFormattingPreset, time: TimeFormatting) => {
+ switch (date) {
+ case DateFormattingPreset.Y:
+ return '%Y';
+ case DateFormattingPreset.M:
+ case DateFormattingPreset.YM:
+ return '%Y-%m';
+ default:
+ return time !== TimeFormatting.None ? '%Y-%m-%d %H:%M' : '%Y-%m-%d';
+ }
+};
diff --git a/apps/nestjs-backend/src/db-provider/group-query/group-query.abstract.ts b/apps/nestjs-backend/src/db-provider/group-query/group-query.abstract.ts
new file mode 100644
index 0000000000..e5449d9c26
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/group-query/group-query.abstract.ts
@@ -0,0 +1,98 @@
+import { Logger } from '@nestjs/common';
+import type { FieldCore } from '@teable/core';
+import { CellValueType } from '@teable/core';
+import type { Knex } from 'knex';
+import type { IRecordQueryGroupContext } from '../../features/record/query-builder/record-query-builder.interface';
+import type { IGroupQueryInterface, IGroupQueryExtra } from './group-query.interface';
+
+export abstract class AbstractGroupQuery implements IGroupQueryInterface {
+ private logger = new Logger(AbstractGroupQuery.name);
+
+ constructor(
+ protected readonly knex: Knex,
+ protected readonly originQueryBuilder: Knex.QueryBuilder,
+ protected readonly fieldMap?: { [fieldId: string]: FieldCore },
+ protected readonly groupFieldIds?: string[],
+ protected readonly extra?: IGroupQueryExtra,
+ protected readonly context?: IRecordQueryGroupContext
+ ) {}
+
+ appendGroupBuilder(): Knex.QueryBuilder {
+ return this.parseGroups(this.originQueryBuilder, this.groupFieldIds);
+ }
+
+ protected getTableColumnName(field: FieldCore): string {
+ const selection = this.context?.selectionMap.get(field.id);
+ if (selection) {
+ return selection as string;
+ }
+ return field.dbFieldName;
+ }
+
+ private parseGroups(
+ queryBuilder: Knex.QueryBuilder,
+ groupFieldIds?: string[]
+ ): Knex.QueryBuilder {
+ if (!groupFieldIds || !groupFieldIds.length) {
+ return queryBuilder;
+ }
+
+ groupFieldIds.forEach((fieldId) => {
+ const field = this.fieldMap?.[fieldId];
+
+ if (!field) {
+ return queryBuilder;
+ }
+ this.getGroupAdapter(field);
+ });
+
+ return queryBuilder;
+ }
+
+ private getGroupAdapter(field: FieldCore): Knex.QueryBuilder {
+ if (!field) return this.originQueryBuilder;
+ const { cellValueType, isMultipleCellValue, isStructuredCellValue } = field;
+
+ if (isMultipleCellValue) {
+ switch (cellValueType) {
+ case CellValueType.DateTime:
+ return this.multipleDate(field);
+ case CellValueType.Number:
+ return this.multipleNumber(field);
+ case CellValueType.String:
+ if (isStructuredCellValue) {
+ return this.json(field);
+ }
+ return this.string(field);
+ default:
+ return this.originQueryBuilder;
+ }
+ }
+
+ switch (cellValueType) {
+ case CellValueType.DateTime:
+ return this.date(field);
+ case CellValueType.Number:
+ return this.number(field);
+ case CellValueType.Boolean:
+ case CellValueType.String: {
+ if (isStructuredCellValue) {
+ return this.json(field);
+ }
+ return this.string(field);
+ }
+ }
+ }
+
+ abstract string(field: FieldCore): Knex.QueryBuilder;
+
+ abstract date(field: FieldCore): Knex.QueryBuilder;
+
+ abstract number(field: FieldCore): Knex.QueryBuilder;
+
+ abstract json(field: FieldCore): Knex.QueryBuilder;
+
+ abstract multipleDate(field: FieldCore): Knex.QueryBuilder;
+
+ abstract multipleNumber(field: FieldCore): Knex.QueryBuilder;
+}
diff --git a/apps/nestjs-backend/src/db-provider/group-query/group-query.interface.ts b/apps/nestjs-backend/src/db-provider/group-query/group-query.interface.ts
new file mode 100644
index 0000000000..db1827bde9
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/group-query/group-query.interface.ts
@@ -0,0 +1,9 @@
+import type { Knex } from 'knex';
+
+export interface IGroupQueryInterface {
+ appendGroupBuilder(): Knex.QueryBuilder;
+}
+
+export interface IGroupQueryExtra {
+ isDistinct?: boolean;
+}
diff --git a/apps/nestjs-backend/src/db-provider/group-query/group-query.postgres.ts b/apps/nestjs-backend/src/db-provider/group-query/group-query.postgres.ts
new file mode 100644
index 0000000000..df8b82a0e6
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/group-query/group-query.postgres.ts
@@ -0,0 +1,199 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import type { INumberFieldOptions, IDateFieldOptions, FieldCore } from '@teable/core';
+import { DateFormattingPreset, TimeFormatting } from '@teable/core';
+import type { Knex } from 'knex';
+import type { IRecordQueryGroupContext } from '../../features/record/query-builder/record-query-builder.interface';
+import { isUserOrLink } from '../../utils/is-user-or-link';
+import { AbstractGroupQuery } from './group-query.abstract';
+import type { IGroupQueryExtra } from './group-query.interface';
+
+export class GroupQueryPostgres extends AbstractGroupQuery {
+ constructor(
+ protected readonly knex: Knex,
+ protected readonly originQueryBuilder: Knex.QueryBuilder,
+ protected readonly fieldMap?: { [fieldId: string]: FieldCore },
+ protected readonly groupFieldIds?: string[],
+ protected readonly extra?: IGroupQueryExtra,
+ protected readonly context?: IRecordQueryGroupContext
+ ) {
+ super(knex, originQueryBuilder, fieldMap, groupFieldIds, extra, context);
+ }
+
+ private get isDistinct() {
+ const { isDistinct } = this.extra ?? {};
+ return isDistinct;
+ }
+
+ string(field: FieldCore): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(columnName);
+ }
+ return this.originQueryBuilder
+ .select({ [field.dbFieldName]: this.knex.raw(columnName) })
+ .groupByRaw(columnName);
+ }
+
+ number(field: FieldCore): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { precision = 0 } = (options as INumberFieldOptions).formatting ?? {};
+ const column = this.knex.raw(
+ `ROUND(${columnName}::numeric, ?::int)::float as "${field.dbFieldName}"`,
+ [precision]
+ );
+ const groupByColumn = this.knex.raw(`ROUND(${columnName}::numeric, ?::int)::float`, [
+ precision,
+ ]);
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ private resolveDateTruncUnit(
+ datePreset: DateFormattingPreset,
+ time: TimeFormatting
+ ): 'year' | 'month' | 'day' | 'minute' {
+ switch (datePreset) {
+ case DateFormattingPreset.Y:
+ return 'year';
+ case DateFormattingPreset.M:
+ case DateFormattingPreset.YM:
+ return 'month';
+ default:
+ return time !== TimeFormatting.None ? 'minute' : 'day';
+ }
+ }
+
+ date(field: FieldCore): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const unit = this.resolveDateTruncUnit(date as DateFormattingPreset, time);
+ const dbFieldAlias = field.dbFieldName.replace(/"/g, '""');
+
+ // Use timestamptz group keys:
+ // 1) Convert to local timestamp via TIMEZONE(tz, timestamptz)
+ // 2) DATE_TRUNC in local time
+ // 3) Convert back to timestamptz via TIMEZONE(tz, timestamp)
+ const groupExpr = `TIMEZONE(?, DATE_TRUNC(?, TIMEZONE(?, ${columnName})))`;
+ const bindings = [timeZone, unit, timeZone] as const;
+
+ const column = this.knex.raw(`${groupExpr} as "${dbFieldAlias}"`, bindings);
+ const groupByColumn = this.knex.raw(groupExpr, bindings);
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ json(field: FieldCore): Knex.QueryBuilder {
+ const { type, isMultipleCellValue } = field;
+ const columnName = this.getTableColumnName(field);
+
+ if (this.isDistinct) {
+ if (isUserOrLink(type)) {
+ if (!isMultipleCellValue) {
+ const column = this.knex.raw(`${columnName}::jsonb ->> 'id'`);
+
+ return this.originQueryBuilder.countDistinct(column);
+ }
+
+ const column = this.knex.raw(
+ `jsonb_path_query_array(${columnName}::jsonb, '$[*].id')::text`
+ );
+
+ return this.originQueryBuilder.countDistinct(column);
+ }
+ return this.originQueryBuilder.countDistinct(columnName);
+ }
+
+ if (isUserOrLink(type)) {
+ if (!isMultipleCellValue) {
+ const column = this.knex.raw(
+ `NULLIF(jsonb_build_object(
+ 'id', ${columnName}::jsonb ->> 'id',
+ 'title', ${columnName}::jsonb ->> 'title'
+ ), '{"id":null,"title":null}') as "${field.dbFieldName}"`
+ );
+ const groupByColumn = this.knex.raw(
+ `${columnName}::jsonb ->> 'id', ${columnName}::jsonb ->> 'title'`
+ );
+
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ const column = this.knex.raw(
+ `(jsonb_agg(${columnName}::jsonb) -> 0) as "${field.dbFieldName}"`
+ );
+ const groupByColumn = this.knex.raw(
+ `jsonb_path_query_array(${columnName}::jsonb, '$[*].id')::text, jsonb_path_query_array(${columnName}::jsonb, '$[*].title')::text`
+ );
+
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ const column = this.knex.raw(`CAST(${columnName} as text)`);
+ return this.originQueryBuilder.select(column).groupByRaw(columnName);
+ }
+
+ multipleDate(field: FieldCore): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const unit = this.resolveDateTruncUnit(date as DateFormattingPreset, time);
+ const dbFieldAlias = field.dbFieldName.replace(/"/g, '""');
+
+ const elemExpr = `TIMEZONE(?, DATE_TRUNC(?, TIMEZONE(?, CAST(elem AS timestamp with time zone))))`;
+ const elemBindings = [timeZone, unit, timeZone] as const;
+
+ const column = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(${elemExpr}))
+ FROM jsonb_array_elements_text(${columnName}::jsonb) as elem) as "${dbFieldAlias}"
+ `,
+ elemBindings
+ );
+ const groupByColumn = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(${elemExpr}))
+ FROM jsonb_array_elements_text(${columnName}::jsonb) as elem)
+ `,
+ elemBindings
+ );
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ multipleNumber(field: FieldCore): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { precision = 0 } = (options as INumberFieldOptions).formatting ?? {};
+ const column = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))
+ FROM jsonb_array_elements_text(${columnName}::jsonb) as elem) as "${field.dbFieldName}"
+ `,
+ [precision]
+ );
+ const groupByColumn = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))
+ FROM jsonb_array_elements_text(${columnName}::jsonb) as elem)
+ `,
+ [precision]
+ );
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/group-query/group-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/group-query/group-query.sqlite.ts
new file mode 100644
index 0000000000..bb5cae054a
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/group-query/group-query.sqlite.ts
@@ -0,0 +1,169 @@
+import type { DateFormattingPreset, INumberFieldOptions, IDateFieldOptions } from '@teable/core';
+import type { Knex } from 'knex';
+import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryGroupContext } from '../../features/record/query-builder/record-query-builder.interface';
+import { isUserOrLink } from '../../utils/is-user-or-link';
+import { getOffset } from '../search-query/get-offset';
+import { getSqliteDateTimeFormatString } from './format-string';
+import { AbstractGroupQuery } from './group-query.abstract';
+import type { IGroupQueryExtra } from './group-query.interface';
+
+export class GroupQuerySqlite extends AbstractGroupQuery {
+ constructor(
+ protected readonly knex: Knex,
+ protected readonly originQueryBuilder: Knex.QueryBuilder,
+ protected readonly fieldMap?: { [fieldId: string]: IFieldInstance },
+ protected readonly groupFieldIds?: string[],
+ protected readonly extra?: IGroupQueryExtra,
+ protected readonly context?: IRecordQueryGroupContext
+ ) {
+ super(knex, originQueryBuilder, fieldMap, groupFieldIds, extra, context);
+ }
+
+ private get isDistinct() {
+ const { isDistinct } = this.extra ?? {};
+ return isDistinct;
+ }
+
+ string(field: IFieldInstance): Knex.QueryBuilder {
+ if (!field) return this.originQueryBuilder;
+
+ const columnName = this.getTableColumnName(field);
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(columnName);
+ }
+ return this.originQueryBuilder
+ .select({ [field.dbFieldName]: this.knex.raw(columnName) })
+ .groupByRaw(columnName);
+ }
+
+ number(field: IFieldInstance): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const column = this.knex.raw(`ROUND(${columnName}, ?) as ${columnName}`, [precision]);
+ const groupByColumn = this.knex.raw(`ROUND(${columnName}, ?)`, [precision]);
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ date(field: IFieldInstance): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetStr = `${getOffset(timeZone)} hour`;
+ const column = this.knex.raw(`strftime(?, DATETIME(${columnName}, ?)) as ${columnName}`, [
+ formatString,
+ offsetStr,
+ ]);
+ const groupByColumn = this.knex.raw(`strftime(?, DATETIME(${columnName}, ?))`, [
+ formatString,
+ offsetStr,
+ ]);
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ json(field: IFieldInstance): Knex.QueryBuilder {
+ const { type, isMultipleCellValue } = field;
+ const columnName = this.getTableColumnName(field);
+
+ if (this.isDistinct) {
+ if (isUserOrLink(type)) {
+ if (!isMultipleCellValue) {
+ const groupByColumn = this.knex.raw(
+ `json_extract(${columnName}, '$.id') || json_extract(${columnName}, '$.title')`
+ );
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ const groupByColumn = this.knex.raw(`json_extract(${columnName}, '$[0].id', '$[0].title')`);
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.countDistinct(columnName);
+ }
+
+ if (isUserOrLink(type)) {
+ if (!isMultipleCellValue) {
+ const groupByColumn = this.knex.raw(
+ `json_extract(${columnName}, '$.id') || json_extract(${columnName}, '$.title')`
+ );
+ return this.originQueryBuilder.select(columnName).groupBy(groupByColumn);
+ }
+
+ const groupByColumn = this.knex.raw(`json_extract(${columnName}, '$[0].id', '$[0].title')`);
+ return this.originQueryBuilder.select(columnName).groupBy(groupByColumn);
+ }
+
+ const column = this.knex.raw(`CAST(${columnName} as text) as ${columnName}`);
+ return this.originQueryBuilder.select(column).groupByRaw(columnName);
+ }
+
+ multipleDate(field: IFieldInstance): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+
+ const offsetStr = `${getOffset(timeZone)} hour`;
+ const column = this.knex.raw(
+ `
+ (
+ SELECT json_group_array(strftime(?, DATETIME(value, ?)))
+ FROM json_each(${columnName})
+ ) as ${columnName}
+ `,
+ [formatString, offsetStr]
+ );
+ const groupByColumn = this.knex.raw(
+ `
+ (
+ SELECT json_group_array(strftime(?, DATETIME(value, ?)))
+ FROM json_each(${columnName})
+ )
+ `,
+ [formatString, offsetStr]
+ );
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+
+ multipleNumber(field: IFieldInstance): Knex.QueryBuilder {
+ const columnName = this.getTableColumnName(field);
+ const { options } = field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const column = this.knex.raw(
+ `
+ (
+ SELECT json_group_array(ROUND(value, ?))
+ FROM json_each(${columnName})
+ ) as ${columnName}
+ `,
+ [precision]
+ );
+ const groupByColumn = this.knex.raw(
+ `
+ (
+ SELECT json_group_array(ROUND(value, ?))
+ FROM json_each(${columnName})
+ )
+ `,
+ [precision]
+ );
+
+ if (this.isDistinct) {
+ return this.originQueryBuilder.countDistinct(groupByColumn);
+ }
+ return this.originQueryBuilder.select(column).groupBy(groupByColumn);
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/index-query/index-abstract-builder.ts b/apps/nestjs-backend/src/db-provider/index-query/index-abstract-builder.ts
new file mode 100644
index 0000000000..2f6a70bd99
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/index-query/index-abstract-builder.ts
@@ -0,0 +1,28 @@
+import type { IGetAbnormalVo } from '@teable/openapi';
+import type { IFieldInstance } from '../../features/field/model/factory';
+
+export abstract class IndexBuilderAbstract {
+ abstract getDropIndexSql(dbTableName: string): string;
+
+ abstract getCreateIndexSql(dbTableName: string, searchFields: IFieldInstance[]): string[];
+
+ abstract getExistTableIndexSql(dbTableName: string): string;
+
+ abstract getDeleteSingleIndexSql(dbTableName: string, field: IFieldInstance): string;
+
+ abstract getUpdateSingleIndexNameSql(
+ dbTableName: string,
+ oldField: Pick,
+ newField: Pick
+ ): string;
+
+ abstract createSingleIndexSql(dbTableName: string, field: IFieldInstance): string | null;
+
+ abstract getIndexInfoSql(dbTableName: string): string;
+
+ abstract getAbnormalIndex(
+ dbTableName: string,
+ fields: IFieldInstance[],
+ existingIndex: unknown[]
+ ): IGetAbnormalVo;
+}
diff --git a/apps/nestjs-backend/src/db-provider/integrity-query/abstract.ts b/apps/nestjs-backend/src/db-provider/integrity-query/abstract.ts
new file mode 100644
index 0000000000..287a8454c2
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/integrity-query/abstract.ts
@@ -0,0 +1,40 @@
+import type { Knex } from 'knex';
+
+export abstract class IntegrityQueryAbstract {
+ constructor(protected readonly knex: Knex) {}
+
+ abstract checkLinks(params: {
+ dbTableName: string;
+ fkHostTableName: string;
+ selfKeyName: string;
+ foreignKeyName: string;
+ linkDbFieldName: string;
+ isMultiValue: boolean;
+ }): string;
+
+ abstract fixLinks(params: {
+ dbTableName: string;
+ fkHostTableName: string;
+ selfKeyName: string;
+ foreignKeyName: string;
+ linkDbFieldName: string;
+ isMultiValue: boolean;
+ }): string;
+
+ /**
+ * Deprecated: Do NOT use in new code.
+ * Link fields do not persist a display JSON column; their values are derived
+ * from junction tables or foreign key columns. This helper was only used by
+ * legacy tests to mutate a hypothetical JSON display column to simulate
+ * inconsistencies. Prefer modifying the junction/fk data directly.
+ *
+ * @deprecated Use junction table / foreign key mutations instead.
+ */
+ abstract updateJsonField(params: {
+ recordIds: string[];
+ dbTableName: string;
+ field: string;
+ value: string | number | boolean | null;
+ arrayIndex?: number;
+ }): Knex.QueryBuilder;
+}
diff --git a/apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.postgres.ts b/apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.postgres.ts
new file mode 100644
index 0000000000..5cbf412d3e
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.postgres.ts
@@ -0,0 +1,207 @@
+import type { Knex } from 'knex';
+import { IntegrityQueryAbstract } from './abstract';
+
+export class IntegrityQueryPostgres extends IntegrityQueryAbstract {
+ constructor(protected readonly knex: Knex) {
+ super(knex);
+ }
+
+ checkLinks({
+ dbTableName,
+ fkHostTableName,
+ selfKeyName,
+ foreignKeyName,
+ linkDbFieldName,
+ isMultiValue,
+ }: {
+ dbTableName: string;
+ fkHostTableName: string;
+ selfKeyName: string;
+ foreignKeyName: string;
+ linkDbFieldName: string;
+ isMultiValue: boolean;
+ }): string {
+ // Multi-value relationships (ManyMany, OneMany)
+ if (isMultiValue) {
+ const fkGroupedQuery = this.knex(fkHostTableName)
+ .select({
+ [selfKeyName]: selfKeyName,
+ fk_ids: this.knex.raw(`string_agg(??, ',' ORDER BY ??)`, [
+ this.knex.ref(foreignKeyName),
+ this.knex.ref(foreignKeyName),
+ ]),
+ })
+ .whereNotNull(selfKeyName)
+ .groupBy(selfKeyName)
+ .as('fk_grouped');
+
+ // Always alias main table as t1 to avoid ambiguous identifiers
+ return this.knex(`${dbTableName} as t1`)
+ .leftJoin(fkGroupedQuery, `t1.__id`, `fk_grouped.${selfKeyName}`)
+ .select({ id: 't1.__id' })
+ .where(function () {
+ this.whereNull(`fk_grouped.${selfKeyName}`)
+ .whereRaw(`"t1"."${linkDbFieldName}" IS NOT NULL`)
+ .orWhere(function () {
+ // Compare aggregated FK ids with ids from JSON array in link column
+ this.whereRaw(`"t1"."${linkDbFieldName}" IS NOT NULL`).andWhereRaw(
+ `"fk_grouped".fk_ids != (
+ SELECT string_agg(id, ',' ORDER BY id)
+ FROM (
+ SELECT (link->>'id')::text as id
+ FROM jsonb_array_elements(("t1"."${linkDbFieldName}")::jsonb) as link
+ ) t
+ )`
+ );
+ });
+ })
+ .toQuery();
+ }
+
+ // Single-value relationships where FK is in the same table as the link field (ManyOne/OneOne on main table)
+ if (fkHostTableName === dbTableName) {
+ return this.knex(`${dbTableName} as t1`)
+ .select({ id: 't1.__id' })
+ .where(function () {
+ this.whereRaw(`"t1"."${foreignKeyName}" IS NULL`)
+ .whereRaw(`"t1"."${linkDbFieldName}" IS NOT NULL`)
+ .orWhere(function () {
+ this.whereRaw(`"t1"."${linkDbFieldName}" IS NOT NULL`).andWhereRaw(
+ `("t1"."${linkDbFieldName}"->>'id')::text != "t1"."${foreignKeyName}"::text`
+ );
+ });
+ })
+ .toQuery();
+ }
+
+ // Single-value relationships where FK is stored in another host table (e.g., OneOne with FK on the other side)
+ return this.knex(`${dbTableName} as t1`)
+ .select({ id: 't1.__id' })
+ .leftJoin(`${fkHostTableName} as t2`, 't2.' + selfKeyName, 't1.__id')
+ .where(function () {
+ this.whereRaw(`"t2"."${foreignKeyName}" IS NULL`)
+ .whereRaw(`"t1"."${linkDbFieldName}" IS NOT NULL`)
+ .orWhere(function () {
+ this.whereRaw(`"t1"."${linkDbFieldName}" IS NOT NULL`).andWhereRaw(
+ `("t1"."${linkDbFieldName}"->>'id')::text != "t2"."${foreignKeyName}"::text`
+ );
+ });
+ })
+ .toQuery();
+ }
+
+ fixLinks({
+ recordIds,
+ dbTableName,
+ foreignDbTableName,
+ fkHostTableName,
+ lookupDbFieldName,
+ selfKeyName,
+ foreignKeyName,
+ linkDbFieldName,
+ isMultiValue,
+ }: {
+ recordIds: string[];
+ dbTableName: string;
+ foreignDbTableName: string;
+ fkHostTableName: string;
+ lookupDbFieldName: string;
+ selfKeyName: string;
+ foreignKeyName: string;
+ linkDbFieldName: string;
+ isMultiValue: boolean;
+ }): string {
+ if (isMultiValue) {
+ return this.knex(dbTableName)
+ .update({
+ [linkDbFieldName]: this.knex
+ .select(
+ this.knex.raw("jsonb_agg(jsonb_build_object('id', ??, 'title', ??) ORDER BY ??)", [
+ `fk.${foreignKeyName}`,
+ `ft.${lookupDbFieldName}`,
+ `fk.${foreignKeyName}`,
+ ])
+ )
+ .from(`${fkHostTableName} as fk`)
+ .join(`${foreignDbTableName} as ft`, `ft.__id`, `fk.${foreignKeyName}`)
+ .where('fk.' + selfKeyName, `${dbTableName}.__id`),
+ })
+ .whereIn('__id', recordIds)
+ .toQuery();
+ }
+
+ if (fkHostTableName === dbTableName) {
+ // Handle self-referential single-value links
+ return this.knex(dbTableName)
+ .update({
+ [linkDbFieldName]: this.knex.raw(
+ `
+ CASE
+ WHEN ?? IS NULL THEN NULL
+ ELSE jsonb_build_object(
+ 'id', ??,
+ 'title', (SELECT ?? FROM ?? WHERE __id = ??)
+ )
+ END
+ `,
+ [foreignKeyName, foreignKeyName, lookupDbFieldName, foreignDbTableName, foreignKeyName]
+ ),
+ })
+ .whereIn('__id', recordIds)
+ .toQuery();
+ }
+
+ // Handle cross-table single-value links
+ return this.knex(dbTableName)
+ .update({
+ [linkDbFieldName]: this.knex
+ .select(
+ this.knex.raw(
+ `CASE
+ WHEN t2.?? IS NULL THEN NULL
+ ELSE jsonb_build_object('id', t2.??, 'title', t2.??)
+ END`,
+ [foreignKeyName, foreignKeyName, lookupDbFieldName]
+ )
+ )
+ .from(`${fkHostTableName} as t2`)
+ .where(`t2.${foreignKeyName}`, `${dbTableName}.__id`)
+ .limit(1),
+ })
+ .whereIn('__id', recordIds)
+ .toQuery();
+ }
+
+ /**
+ * Deprecated: Do NOT use in new code.
+ * Link fields typically do not persist a display JSON column in Postgres;
+ * their values are computed from junction tables or fk columns. This method
+ * exists only for legacy tests that used to mutate a JSON display column to
+ * create inconsistencies. Prefer changing junction/fk data directly.
+ *
+ * @deprecated Use junction/fk mutations instead of updating a JSON column.
+ */
+ updateJsonField({
+ recordIds,
+ dbTableName,
+ field,
+ value,
+ arrayIndex,
+ }: {
+ recordIds: string[];
+ dbTableName: string;
+ field: string;
+ value: string | number | boolean | null;
+ arrayIndex?: number;
+ }) {
+ return this.knex(dbTableName)
+ .whereIn('__id', recordIds)
+ .update({
+ [field]: this.knex.raw(`jsonb_set(
+ "${field}",
+ '${arrayIndex != null ? `{${arrayIndex},id}` : '{id}'}',
+ '${JSON.stringify(value)}'
+ )`),
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.sqlite.ts
new file mode 100644
index 0000000000..8da5ffc04b
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/integrity-query/integrity-query.sqlite.ts
@@ -0,0 +1,249 @@
+import type { Knex } from 'knex';
+import { IntegrityQueryAbstract } from './abstract';
+
+export class IntegrityQuerySqlite extends IntegrityQueryAbstract {
+ constructor(protected readonly knex: Knex) {
+ super(knex);
+ }
+
+ checkLinks({
+ dbTableName,
+ fkHostTableName,
+ selfKeyName,
+ foreignKeyName,
+ linkDbFieldName,
+ isMultiValue,
+ }: {
+ dbTableName: string;
+ fkHostTableName: string;
+ selfKeyName: string;
+ foreignKeyName: string;
+ linkDbFieldName: string;
+ isMultiValue: boolean;
+ }): string {
+ const thisKnex = this.knex;
+ if (isMultiValue) {
+ const fkGroupedQuery = this.knex(fkHostTableName)
+ .select({
+ [selfKeyName]: selfKeyName,
+ fk_ids: this.knex.raw(`GROUP_CONCAT(??)`, [this.knex.ref(foreignKeyName)]),
+ })
+ .whereNotNull(selfKeyName)
+ .groupBy(selfKeyName)
+ .as('fk_grouped');
+ return this.knex(dbTableName)
+ .leftJoin(fkGroupedQuery, `${dbTableName}.__id`, `fk_grouped.${selfKeyName}`)
+ .select({
+ id: '__id',
+ })
+ .where(function () {
+ this.whereNull(`fk_grouped.${selfKeyName}`)
+ .whereNotNull(linkDbFieldName)
+ .orWhere(function () {
+ this.whereNotNull(linkDbFieldName).andWhereRaw(
+ `"fk_grouped".fk_ids != (
+ SELECT GROUP_CONCAT(id)
+ FROM (
+ SELECT json_extract(link.value, '$.id') as id
+ FROM json_each(?) as link
+ ) t
+ )`,
+ [thisKnex.ref(linkDbFieldName)]
+ );
+ });
+ })
+ .toQuery();
+ }
+
+ if (fkHostTableName === dbTableName) {
+ return this.knex(dbTableName)
+ .select({
+ id: '__id',
+ })
+ .where(function () {
+ this.whereNull(foreignKeyName)
+ .whereNotNull(linkDbFieldName)
+ .orWhere(function () {
+ this.whereNotNull(linkDbFieldName).andWhereRaw(
+ `json_extract(??, '$.id') != CAST(${foreignKeyName} AS TEXT)`,
+ [thisKnex.ref(linkDbFieldName)]
+ );
+ });
+ })
+ .toQuery();
+ }
+
+ if (dbTableName === fkHostTableName) {
+ return this.knex(`${dbTableName} as t1`)
+ .select({
+ id: 't1.__id',
+ })
+ .leftJoin(`${dbTableName} as t2`, 't2.' + foreignKeyName, 't1.__id')
+ .where(function () {
+ this.whereNull('t2.' + foreignKeyName)
+ .whereNotNull('t1.' + linkDbFieldName)
+ .orWhere(function () {
+ this.whereNotNull('t1.' + linkDbFieldName).andWhereRaw(
+ `json_extract(t1."${linkDbFieldName}", '$.id') != CAST(t2."${foreignKeyName}" AS TEXT)`
+ );
+ });
+ })
+ .toQuery();
+ }
+
+ return this.knex(`${dbTableName} as t1`)
+ .select({
+ id: 't1.__id',
+ })
+ .leftJoin(`${fkHostTableName} as t2`, 't2.' + selfKeyName, 't1.__id')
+ .where(function () {
+ this.whereNull('t2.' + foreignKeyName)
+ .whereNotNull('t1.' + linkDbFieldName)
+ .orWhere(function () {
+ this.whereNotNull('t1.' + linkDbFieldName).andWhereRaw(
+ `json_extract(t1."${linkDbFieldName}", '$.id') != CAST(t2."${foreignKeyName}" AS TEXT)`
+ );
+ });
+ })
+ .toQuery();
+ }
+
+ fixLinks({
+ recordIds,
+ dbTableName,
+ foreignDbTableName,
+ fkHostTableName,
+ lookupDbFieldName,
+ selfKeyName,
+ foreignKeyName,
+ linkDbFieldName,
+ isMultiValue,
+ }: {
+ recordIds: string[];
+ dbTableName: string;
+ foreignDbTableName: string;
+ fkHostTableName: string;
+ lookupDbFieldName: string;
+ selfKeyName: string;
+ foreignKeyName: string;
+ linkDbFieldName: string;
+ isMultiValue: boolean;
+ }): string {
+ if (isMultiValue) {
+ return this.knex(dbTableName)
+ .update({
+ [linkDbFieldName]: this.knex
+ .select(
+ this.knex.raw(
+ `json_group_array(
+ json_object(
+ 'id', fk.${foreignKeyName},
+ 'title', ft.${lookupDbFieldName}
+ )
+ )`
+ )
+ )
+ .from(`${fkHostTableName} as fk`)
+ .join(`${foreignDbTableName} as ft`, `ft.__id`, `fk.${foreignKeyName}`)
+ .where('fk.' + selfKeyName, `${dbTableName}.__id`)
+ .orderBy(`fk.${foreignKeyName}`),
+ })
+ .whereIn('__id', recordIds)
+ .toQuery();
+ }
+
+ if (fkHostTableName === dbTableName) {
+ // Handle self-referential single-value links
+ return this.knex(dbTableName)
+ .update({
+ [linkDbFieldName]: this.knex.raw(
+ `
+ CASE
+ WHEN ?? IS NULL THEN NULL
+ ELSE json_object(
+ 'id', ??,
+ 'title', ??
+ )
+ END
+ `,
+ [foreignKeyName, foreignKeyName, lookupDbFieldName]
+ ),
+ })
+ .whereIn('__id', recordIds)
+ .toQuery();
+ }
+
+ // Handle cross-table single-value links
+ return this.knex(dbTableName)
+ .update({
+ [linkDbFieldName]: this.knex
+ .select(
+ this.knex.raw(
+ `CASE
+ WHEN t2.?? IS NULL THEN NULL
+ ELSE json_object('id', t2.??, 'title', t2.??)
+ END`,
+ [foreignKeyName, foreignKeyName, lookupDbFieldName]
+ )
+ )
+ .from(`${fkHostTableName} as t2`)
+ .where(`t2.${foreignKeyName}`, `${dbTableName}.__id`)
+ .limit(1),
+ })
+ .whereIn('__id', recordIds)
+ .toQuery();
+ }
+
+ /**
+ * Deprecated: Do NOT use in new code.
+ * Link fields' display values are derived; avoid updating a JSON column.
+ * This exists only for legacy tests; prefer mutating junction/fk data.
+ *
+ * @deprecated Use junction/fk mutations instead of updating a JSON column.
+ */
+ updateJsonField({
+ recordIds,
+ dbTableName,
+ field,
+ value,
+ arrayIndex,
+ }: {
+ recordIds: string[];
+ dbTableName: string;
+ field: string;
+ value: string | number | boolean | null;
+ arrayIndex?: number;
+ }) {
+ if (arrayIndex != null) {
+ // For array elements, we need to use json_replace with json_extract
+ return this.knex(dbTableName)
+ .whereIn('__id', recordIds)
+ .update({
+ [field]: this.knex.raw(
+ `
+ json_replace(
+ "${field}",
+ '$[' || ? || '].id',
+ json(?))
+ `,
+ [arrayIndex, JSON.stringify(value)]
+ ),
+ });
+ }
+
+ // For single value
+ return this.knex(dbTableName)
+ .whereIn('__id', recordIds)
+ .update({
+ [field]: this.knex.raw(
+ `
+ json_replace(
+ "${field}",
+ '$.id',
+ json(?))
+ `,
+ [JSON.stringify(value)]
+ ),
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/postgres.provider.ts b/apps/nestjs-backend/src/db-provider/postgres.provider.ts
index a82fb99729..fb035c4236 100644
--- a/apps/nestjs-backend/src/db-provider/postgres.provider.ts
+++ b/apps/nestjs-backend/src/db-provider/postgres.provider.ts
@@ -1,19 +1,69 @@
+/* eslint-disable sonarjs/no-duplicate-string */
import { Logger } from '@nestjs/common';
-import type { IAggregationField, IFilter, ISortItem } from '@teable/core';
-import { DriverClient } from '@teable/core';
+import type {
+ IFilter,
+ ILookupLinkOptionsVo,
+ ISortItem,
+ TableDomain,
+ FieldCore,
+} from '@teable/core';
+import { DriverClient, parseFormulaToSQL, FieldType } from '@teable/core';
+import type { PrismaClient } from '@teable/db-main-prisma';
+import type { IAggregationField, ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';
import type { Knex } from 'knex';
import type { IFieldInstance } from '../features/field/model/factory';
-import type { SchemaType } from '../features/field/util';
+import type { IFieldSelectName } from '../features/record/query-builder/field-select.type';
+import type {
+ IRecordQueryFilterContext,
+ IRecordQuerySortContext,
+ IRecordQueryGroupContext,
+ IRecordQueryAggregateContext,
+} from '../features/record/query-builder/record-query-builder.interface';
+import type {
+ IGeneratedColumnQueryInterface,
+ IFormulaConversionContext,
+ IFormulaConversionResult,
+ ISelectQueryInterface,
+ ISelectFormulaConversionContext,
+} from '../features/record/query-builder/sql-conversion.visitor';
+import {
+ GeneratedColumnSqlConversionVisitor,
+ SelectColumnSqlConversionVisitor,
+} from '../features/record/query-builder/sql-conversion.visitor';
import type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';
import { AggregationQueryPostgres } from './aggregation-query/postgres/aggregation-query.postgres';
+import type { BaseQueryAbstract } from './base-query/abstract';
+import { BaseQueryPostgres } from './base-query/base-query.postgres';
+import type { ICreateDatabaseColumnContext } from './create-database-column-query/create-database-column-field-visitor.interface';
+import { CreatePostgresDatabaseColumnFieldVisitor } from './create-database-column-query/create-database-column-field-visitor.postgres';
import type {
IAggregationQueryExtra,
+ ICalendarDailyCollectionQueryProps,
IDbProvider,
IFilterQueryExtra,
ISortQueryExtra,
} from './db.provider.interface';
+import type {
+ IDropDatabaseColumnContext,
+ DropColumnOperationType,
+} from './drop-database-column-query/drop-database-column-field-visitor.interface';
+import { DropPostgresDatabaseColumnFieldVisitor } from './drop-database-column-query/drop-database-column-field-visitor.postgres';
+import { DuplicateAttachmentTableQueryPostgres } from './duplicate-table/duplicate-attachment-table-query.postgres';
+import { DuplicateTableQueryPostgres } from './duplicate-table/duplicate-query.postgres';
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
import { FilterQueryPostgres } from './filter-query/postgres/filter-query.postgres';
+import { GeneratedColumnQueryPostgres } from './generated-column-query/postgres/generated-column-query.postgres';
+import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
+import { GroupQueryPostgres } from './group-query/group-query.postgres';
+import type { IntegrityQueryAbstract } from './integrity-query/abstract';
+import { IntegrityQueryPostgres } from './integrity-query/integrity-query.postgres';
+import { SearchQueryAbstract } from './search-query/abstract';
+import { IndexBuilderPostgres } from './search-query/search-index-builder.postgres';
+import {
+ SearchQueryPostgresBuilder,
+ SearchQueryPostgres,
+} from './search-query/search-query.postgres';
+import { SelectQueryPostgres } from './select-query/postgres/select-query.postgres';
import { SortQueryPostgres } from './sort-query/postgres/sort-query.postgres';
import type { ISortQueryInterface } from './sort-query/sort-query.interface';
@@ -30,10 +80,40 @@ export class PostgresProvider implements IDbProvider {
];
}
+ dropSchema(schemaName: string): string {
+ return this.knex.raw(`DROP SCHEMA IF EXISTS ?? CASCADE`, [schemaName]).toQuery();
+ }
+
generateDbTableName(baseId: string, name: string) {
return `${baseId}.${name}`;
}
+ getForeignKeysInfo(dbTableName: string) {
+ const [schemaName, tableName] = this.splitTableName(dbTableName);
+ return this.knex
+ .raw(
+ `
+ SELECT tc.constraint_name,
+ kcu.column_name,
+ ccu.table_schema AS referenced_table_schema,
+ ccu.table_name AS referenced_table_name,
+ ccu.column_name AS referenced_column_name
+FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu
+ ON tc.constraint_name = kcu.constraint_name
+ AND tc.table_schema = kcu.table_schema
+ JOIN information_schema.constraint_column_usage ccu
+ ON ccu.constraint_name = tc.constraint_name
+ AND ccu.table_schema = tc.table_schema
+WHERE tc.constraint_type = 'FOREIGN KEY'
+ AND tc.table_schema = ?
+ AND tc.table_name = ?;
+ `,
+ [schemaName, tableName]
+ )
+ .toQuery();
+ }
+
renameTableName(oldTableName: string, newTableName: string) {
const nameWithoutSchema = this.splitTableName(newTableName)[1];
return [
@@ -42,11 +122,36 @@ export class PostgresProvider implements IDbProvider {
}
dropTable(tableName: string): string {
+ return this.knex.raw('DROP TABLE IF EXISTS ?? CASCADE', [tableName]).toQuery();
+ }
+
+ async checkColumnExist(
+ tableName: string,
+ columnName: string,
+ prisma: PrismaClient
+ ): Promise {
const [schemaName, dbTableName] = this.splitTableName(tableName);
- return this.knex.raw('DROP TABLE ??.??', [schemaName, dbTableName]).toQuery();
+ const sql = this.knex
+ .raw(
+ 'SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_schema = ? AND table_name = ? AND column_name = ?) AS exists',
+ [schemaName, dbTableName, columnName]
+ )
+ .toQuery();
+ const res = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(sql);
+ return res[0].exists;
}
- renameColumnName(tableName: string, oldName: string, newName: string): string[] {
+ checkTableExist(tableName: string): string {
+ const [schemaName, dbTableName] = this.splitTableName(tableName);
+ return this.knex
+ .raw(
+ 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = ? AND table_name = ?) AS exists',
+ [schemaName, dbTableName]
+ )
+ .toQuery();
+ }
+
+ renameColumn(tableName: string, oldName: string, newName: string): string[] {
return this.knex.schema
.alterTable(tableName, (table) => {
table.renameColumn(oldName, newName);
@@ -55,18 +160,33 @@ export class PostgresProvider implements IDbProvider {
.map((item) => item.sql);
}
- dropColumn(tableName: string, columnName: string): string[] {
- return this.knex.schema
- .alterTable(tableName, (table) => {
- table.dropColumn(columnName);
- })
- .toSQL()
- .map((item) => item.sql);
+ dropColumn(
+ tableName: string,
+ fieldInstance: IFieldInstance,
+ linkContext?: { tableId: string; tableNameMap: Map },
+ operationType?: DropColumnOperationType
+ ): string[] {
+ const context: IDropDatabaseColumnContext = {
+ tableName,
+ knex: this.knex,
+ linkContext,
+ operationType,
+ };
+
+ // Use visitor pattern to drop columns
+ const visitor = new DropPostgresDatabaseColumnFieldVisitor(context);
+ return fieldInstance.accept(visitor);
}
// postgres drop index with column automatically
dropColumnAndIndex(tableName: string, columnName: string, _indexName: string): string[] {
- return this.dropColumn(tableName, columnName);
+ // Use CASCADE to automatically drop dependent objects (like generated columns)
+ // This is safe because we handle application-level dependencies separately
+ return [
+ this.knex
+ .raw('ALTER TABLE ?? DROP COLUMN IF EXISTS ?? CASCADE', [tableName, columnName])
+ .toQuery(),
+ ];
}
columnInfo(tableName: string): string {
@@ -83,19 +203,166 @@ export class PostgresProvider implements IDbProvider {
.toQuery();
}
- modifyColumnSchema(tableName: string, columnName: string, schemaType: SchemaType): string[] {
- return [
- this.knex.schema
- .alterTable(tableName, (table) => {
- table.dropColumn(columnName);
- })
- .toQuery(),
- this.knex.schema
- .alterTable(tableName, (table) => {
- table[schemaType](columnName);
- })
- .toQuery(),
- ];
+ updateJsonColumn(
+ tableName: string,
+ columnName: string,
+ id: string,
+ key: string,
+ value: string
+ ): string {
+ return this.knex(tableName)
+ .where(this.knex.raw(`"${columnName}"->>'id' = ?`, [id]))
+ .update({
+ [columnName]: this.knex.raw(
+ `
+ jsonb_set(
+ "${columnName}",
+ '{${key}}',
+ to_jsonb(?::text)
+ )
+ `,
+ [value]
+ ),
+ })
+ .toQuery();
+ }
+
+ updateJsonArrayColumn(
+ tableName: string,
+ columnName: string,
+ id: string,
+ key: string,
+ value: string
+ ): string {
+ return this.knex(tableName)
+ .update({
+ [columnName]: this.knex.raw(
+ `
+ (
+ SELECT jsonb_agg(
+ CASE
+ WHEN elem->>'id' = ?
+ THEN jsonb_set(elem, '{${key}}', to_jsonb(?::text))
+ ELSE elem
+ END
+ )
+ FROM jsonb_array_elements("${columnName}") AS elem
+ )
+ `,
+ [id, value]
+ ),
+ })
+ .toQuery();
+ }
+
+ modifyColumnSchema(
+ tableName: string,
+ oldFieldInstance: IFieldInstance,
+ fieldInstance: IFieldInstance,
+ tableDomain: TableDomain,
+ linkContext?: { tableId: string; tableNameMap: Map }
+ ): string[] {
+ const queries: string[] = [];
+
+ // First, drop ALL columns associated with the field (including generated columns)
+ queries.push(...this.dropColumn(tableName, oldFieldInstance, linkContext));
+
+ // For Link fields, ensure the host base column exists immediately during modify
+ // to guarantee subsequent update-from-select can persist values. Defer FK/junction
+ // creation to FieldConvertingLinkService (we mark as symmetric here to skip FK creation).
+ if (fieldInstance.type === FieldType.Link && !fieldInstance.isLookup) {
+ const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {
+ const createContext: ICreateDatabaseColumnContext = {
+ table,
+ field: fieldInstance,
+ fieldId: fieldInstance.id,
+ dbFieldName: fieldInstance.dbFieldName,
+ unique: fieldInstance.unique,
+ notNull: fieldInstance.notNull,
+ dbProvider: this,
+ tableDomain,
+ tableId: linkContext?.tableId || '',
+ tableName,
+ knex: this.knex,
+ tableNameMap: linkContext?.tableNameMap || new Map(),
+ // Create base column only; skip FK/junction here
+ isSymmetricField: true,
+ skipBaseColumnCreation: false,
+ };
+ const visitor = new CreatePostgresDatabaseColumnFieldVisitor(createContext);
+ fieldInstance.accept(visitor);
+ });
+ const alterTableQueries = alterTableBuilder.toSQL().map((item) => item.sql);
+ queries.push(...alterTableQueries);
+ return queries;
+ }
+
+ const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {
+ const createContext: ICreateDatabaseColumnContext = {
+ table,
+ field: fieldInstance,
+ fieldId: fieldInstance.id,
+ dbFieldName: fieldInstance.dbFieldName,
+ unique: fieldInstance.unique,
+ notNull: fieldInstance.notNull,
+ dbProvider: this,
+ tableDomain,
+ tableId: linkContext?.tableId || '',
+ tableName,
+ knex: this.knex,
+ tableNameMap: linkContext?.tableNameMap || new Map(),
+ };
+
+ // Use visitor pattern to recreate columns
+ const visitor = new CreatePostgresDatabaseColumnFieldVisitor(createContext);
+ fieldInstance.accept(visitor);
+ });
+
+ const alterTableQueries = alterTableBuilder.toSQL().map((item) => item.sql);
+ queries.push(...alterTableQueries);
+
+ return queries;
+ }
+
+ createColumnSchema(
+ tableName: string,
+ fieldInstance: IFieldInstance,
+ tableDomain: TableDomain,
+ isNewTable: boolean,
+ tableId: string,
+ tableNameMap: Map,
+ isSymmetricField?: boolean,
+ skipBaseColumnCreation?: boolean
+ ): string[] {
+ let visitor: CreatePostgresDatabaseColumnFieldVisitor | undefined = undefined;
+
+ const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {
+ const context: ICreateDatabaseColumnContext = {
+ table,
+ field: fieldInstance,
+ fieldId: fieldInstance.id,
+ dbFieldName: fieldInstance.dbFieldName,
+ unique: fieldInstance.unique,
+ notNull: fieldInstance.notNull,
+ dbProvider: this,
+ tableDomain,
+ isNewTable,
+ tableId,
+ tableName,
+ knex: this.knex,
+ tableNameMap,
+ isSymmetricField,
+ skipBaseColumnCreation,
+ };
+ visitor = new CreatePostgresDatabaseColumnFieldVisitor(context);
+ fieldInstance.accept(visitor);
+ });
+
+ const mainSqls = alterTableBuilder.toSQL().map((item) => item.sql);
+ const additionalSqls =
+ (visitor as CreatePostgresDatabaseColumnFieldVisitor | undefined)?.getSql() ?? [];
+
+ return [...mainSqls, ...additionalSqls].filter(Boolean);
}
splitTableName(tableName: string): string[] {
@@ -183,38 +450,523 @@ export class PostgresProvider implements IDbProvider {
return { insertTempTableSql, updateRecordSql };
}
+ updateFromSelectSql(params: {
+ dbTableName: string;
+ idFieldName: string;
+ subQuery: Knex.QueryBuilder;
+ dbFieldNames: string[];
+ returningDbFieldNames?: string[];
+ restrictRecordIds?: string[];
+ }): string {
+ const {
+ dbTableName,
+ idFieldName,
+ subQuery,
+ dbFieldNames,
+ returningDbFieldNames,
+ restrictRecordIds,
+ } = params;
+ const alias = '__s';
+ const updateColumns = dbFieldNames.reduce<{ [key: string]: unknown }>((acc, name) => {
+ acc[name] = this.knex.ref(`${alias}.${name}`);
+ return acc;
+ }, {});
+ // bump version on target table; qualify to avoid ambiguity with FROM subquery columns
+ updateColumns['__version'] = this.knex.raw('?? + 1', [`${dbTableName}.__version`]);
+
+ const returningCols = [idFieldName, '__version', ...(returningDbFieldNames || dbFieldNames)];
+ const qualifiedReturning = returningCols.map((c) => this.knex.ref(`${dbTableName}.${c}`));
+ // also return previous version for ShareDB op version alignment
+ const returningAll = [
+ ...qualifiedReturning,
+ // Unqualified reference to target table column to avoid FROM-clause issues
+ this.knex.raw('?? - 1 as __prev_version', [`${dbTableName}.__version`]),
+ ];
+ const recordIdsAlias = 'record_ids';
+ const recordIds = restrictRecordIds ?? [];
+ const hasRestrictRecordIds = recordIds.length > 0;
+ const normalizedRecordIds = hasRestrictRecordIds
+ ? Array.from(new Set(recordIds.filter((id) => typeof id === 'string' && id.length > 0)))
+ : [];
+ const recordIdsCte =
+ normalizedRecordIds.length > 0
+ ? this.knex.raw(
+ `select * from (values ${normalizedRecordIds.map(() => '(?)').join(', ')}) as ??(??)`,
+ [...normalizedRecordIds, recordIdsAlias, idFieldName]
+ )
+ : undefined;
+ const fromRaw =
+ recordIdsCte != null
+ ? this.knex.raw('(?) as ??, ??', [subQuery, alias, recordIdsAlias])
+ : this.knex.raw('(?) as ??', [subQuery, alias]);
+
+ const builder = this.knex(dbTableName)
+ .update(updateColumns)
+ .updateFrom(fromRaw)
+ .where(`${dbTableName}.${idFieldName}`, this.knex.ref(`${alias}.${idFieldName}`));
+
+ if (recordIdsCte) {
+ builder
+ .with(recordIdsAlias, recordIdsCte)
+ .where(`${dbTableName}.${idFieldName}`, this.knex.ref(`${recordIdsAlias}.${idFieldName}`));
+ } else if (hasRestrictRecordIds) {
+ builder.whereRaw('1 = 0');
+ }
+
+ const query = builder
+ // Returning is supported on Postgres; qualify to avoid ambiguity with FROM subquery
+ .returning(returningAll as unknown as [])
+ .toQuery();
+ this.logger.debug('updateFromSelectSql: ' + query);
+ return query;
+ }
+
+ lockRecordsSql(params: {
+ dbTableName: string;
+ idFieldName: string;
+ recordIds: string[];
+ }): string | undefined {
+ const { dbTableName, idFieldName, recordIds } = params;
+ const normalized = Array.from(
+ new Set(recordIds.filter((id) => typeof id === 'string' && id.length > 0))
+ );
+ if (!normalized.length) {
+ return undefined;
+ }
+ const ordered = normalized.sort();
+ return this.knex(dbTableName)
+ .select(idFieldName)
+ .whereIn(idFieldName, ordered)
+ .orderBy(idFieldName, 'asc')
+ .forUpdate()
+ .toQuery();
+ }
+
aggregationQuery(
originQueryBuilder: Knex.QueryBuilder,
- dbTableName: string,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
aggregationFields?: IAggregationField[],
- extra?: IAggregationQueryExtra
+ extra?: IAggregationQueryExtra,
+ context?: IRecordQueryAggregateContext
): IAggregationQueryInterface {
return new AggregationQueryPostgres(
this.knex,
originQueryBuilder,
- dbTableName,
fields,
aggregationFields,
- extra
+ extra,
+ context
);
}
filterQuery(
originQueryBuilder: Knex.QueryBuilder,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
filter?: IFilter,
- extra?: IFilterQueryExtra
+ extra?: IFilterQueryExtra,
+ context?: IRecordQueryFilterContext
): IFilterQueryInterface {
- return new FilterQueryPostgres(originQueryBuilder, fields, filter, extra);
+ return new FilterQueryPostgres(originQueryBuilder, fields, filter, extra, this, context);
}
sortQuery(
originQueryBuilder: Knex.QueryBuilder,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
sortObjs?: ISortItem[],
- extra?: ISortQueryExtra
+ extra?: ISortQueryExtra,
+ context?: IRecordQuerySortContext
): ISortQueryInterface {
- return new SortQueryPostgres(this.knex, originQueryBuilder, fields, sortObjs, extra);
+ return new SortQueryPostgres(this.knex, originQueryBuilder, fields, sortObjs, extra, context);
+ }
+
+ groupQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ fieldMap?: { [fieldId: string]: FieldCore },
+ groupFieldIds?: string[],
+ extra?: IGroupQueryExtra,
+ context?: IRecordQueryGroupContext
+ ): IGroupQueryInterface {
+ return new GroupQueryPostgres(
+ this.knex,
+ originQueryBuilder,
+ fieldMap,
+ groupFieldIds,
+ extra,
+ context
+ );
+ }
+
+ searchQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ searchFields: IFieldInstance[],
+ tableIndex: TableIndex[],
+ search: [string, string?, boolean?],
+ context?: IRecordQueryFilterContext
+ ) {
+ return SearchQueryAbstract.appendQueryBuilder(
+ SearchQueryPostgres,
+ originQueryBuilder,
+ searchFields,
+ tableIndex,
+ search,
+ context
+ );
+ }
+
+ searchCountQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ searchField: IFieldInstance[],
+ search: [string, string?, boolean?],
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext
+ ) {
+ return SearchQueryAbstract.buildSearchCountQuery(
+ SearchQueryPostgres,
+ originQueryBuilder,
+ searchField,
+ search,
+ tableIndex,
+ context
+ );
+ }
+
+ searchIndexQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ dbTableName: string,
+ searchField: IFieldInstance[],
+ searchIndexRo: ISearchIndexByQueryRo,
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext,
+ baseSortIndex?: string,
+ setFilterQuery?: (qb: Knex.QueryBuilder) => void,
+ setSortQuery?: (qb: Knex.QueryBuilder) => void
+ ) {
+ return new SearchQueryPostgresBuilder(
+ originQueryBuilder,
+ dbTableName,
+ searchField,
+ searchIndexRo,
+ tableIndex,
+ context,
+ baseSortIndex,
+ setFilterQuery,
+ setSortQuery
+ ).getSearchIndexQuery();
+ }
+
+ searchIndex() {
+ return new IndexBuilderPostgres();
+ }
+
+ duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {
+ return new DuplicateTableQueryPostgres(queryBuilder);
+ }
+
+ duplicateAttachmentTableQuery(queryBuilder: Knex.QueryBuilder) {
+ return new DuplicateAttachmentTableQueryPostgres(queryBuilder);
+ }
+
+ shareFilterCollaboratorsQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ dbFieldName: string,
+ isMultipleCellValue?: boolean
+ ) {
+ if (isMultipleCellValue) {
+ originQueryBuilder.distinct(
+ this.knex.raw(`jsonb_array_elements("${dbFieldName}")->>'id' AS user_id`)
+ );
+ } else {
+ originQueryBuilder.distinct(
+ this.knex.raw(`jsonb_extract_path_text("${dbFieldName}", 'id') AS user_id`)
+ );
+ }
+ }
+
+ baseQuery(): BaseQueryAbstract {
+ return new BaseQueryPostgres(this.knex);
+ }
+
+ integrityQuery(): IntegrityQueryAbstract {
+ return new IntegrityQueryPostgres(this.knex);
+ }
+
+ calendarDailyCollectionQuery(
+ qb: Knex.QueryBuilder,
+ props: ICalendarDailyCollectionQueryProps
+ ): Knex.QueryBuilder {
+ const { startDate, endDate, startField, endField, dbTableName } = props;
+ const timezone = startField.options.formatting.timeZone;
+
+ return qb
+ .select([
+ this.knex.raw('dates.date'),
+ this.knex.raw('COUNT(*) as count'),
+ this.knex.raw(`(array_agg(?? ORDER BY ??.??))[1:10] as ids`, [
+ '__id',
+ dbTableName,
+ startField.dbFieldName,
+ ]),
+ ])
+ .crossJoin(
+ this.knex.raw(
+ `(SELECT date::date as date
+ FROM generate_series(
+ (?::timestamptz AT TIME ZONE ?)::date,
+ (?::timestamptz AT TIME ZONE ?)::date,
+ '1 day'::interval
+ ) AS date) as dates`,
+ [startDate, timezone, endDate, timezone]
+ )
+ )
+ .where((builder) => {
+ builder
+ .whereRaw(
+ `(??.??::timestamptz AT TIME ZONE ?)::date <= (?::timestamptz AT TIME ZONE ?)::date`,
+ [dbTableName, startField.dbFieldName, timezone, endDate, timezone]
+ )
+ .andWhereRaw(
+ `(COALESCE(??.??::timestamptz, ??.??)::timestamptz AT TIME ZONE ?)::date >= (?::timestamptz AT TIME ZONE ?)::date`,
+ [
+ dbTableName,
+ endField.dbFieldName,
+ dbTableName,
+ startField.dbFieldName,
+ timezone,
+ startDate,
+ timezone,
+ ]
+ )
+ .andWhere((subBuilder) => {
+ subBuilder
+ .whereRaw(`(??.??::timestamptz AT TIME ZONE ?)::date <= dates.date`, [
+ dbTableName,
+ startField.dbFieldName,
+ timezone,
+ ])
+ .andWhereRaw(
+ `(COALESCE(??.??::timestamptz, ??.??)::timestamptz AT TIME ZONE ?)::date >= dates.date`,
+ [dbTableName, endField.dbFieldName, dbTableName, startField.dbFieldName, timezone]
+ );
+ });
+ })
+ .groupBy('dates.date')
+ .orderBy('dates.date', 'asc');
+ }
+
+ // select id and lookup_options for "field" table options is a json saved in string format, match optionsKey and value
+ // please use json method in postgres
+ lookupOptionsQuery(optionsKey: keyof ILookupLinkOptionsVo, value: string): string {
+ return this.knex('field')
+ .select({
+ tableId: 'table_id',
+ id: 'id',
+ type: 'type',
+ name: 'name',
+ lookupOptions: 'lookup_options',
+ })
+ .whereNull('deleted_time')
+ .whereRaw(`lookup_options::json->>'${optionsKey}' = ?`, [value])
+ .toQuery();
+ }
+
+ optionsQuery(type: FieldType, optionsKey: string, value: string): string {
+ return this.knex('field')
+ .select({
+ tableId: 'table_id',
+ id: 'id',
+ name: 'name',
+ description: 'description',
+ notNull: 'not_null',
+ unique: 'unique',
+ isPrimary: 'is_primary',
+ dbFieldName: 'db_field_name',
+ isComputed: 'is_computed',
+ isPending: 'is_pending',
+ hasError: 'has_error',
+ dbFieldType: 'db_field_type',
+ isMultipleCellValue: 'is_multiple_cell_value',
+ isLookup: 'is_lookup',
+ lookupOptions: 'lookup_options',
+ type: 'type',
+ options: 'options',
+ cellValueType: 'cell_value_type',
+ })
+ .whereNull('deleted_time')
+ .whereNull('is_lookup')
+ .whereRaw(`options::json->>'${optionsKey}' = ?`, [value])
+ .where('type', type)
+ .toQuery();
+ }
+
+ searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {
+ return qb.where((builder) => {
+ search.forEach(([field, value]) => {
+ builder.orWhere(field, 'ilike', `%${value}%`);
+ });
+ });
+ }
+
+ getTableIndexes(dbTableName: string): string {
+ const [, tableName] = this.splitTableName(dbTableName);
+ return this.knex
+ .raw(
+ `
+ SELECT
+ i.relname AS name,
+ ix.indisunique AS "isUnique",
+ CAST(jsonb_agg(a.attname ORDER BY u.attposition) AS TEXT) AS columns
+FROM
+ pg_class t,
+ pg_class i,
+ pg_index ix,
+ pg_attribute a,
+ unnest(ix.indkey) WITH ORDINALITY u(attnum, attposition)
+WHERE
+ t.oid = ix.indrelid
+ AND i.oid = ix.indexrelid
+ AND a.attrelid = t.oid
+ AND a.attnum = u.attnum
+ AND t.relname = ?
+GROUP BY
+ i.relname,
+ ix.indisunique,
+ ix.indisprimary
+ORDER BY
+ i.relname;
+ `,
+ [tableName]
+ )
+ .toQuery();
+ }
+
+ generatedColumnQuery(): IGeneratedColumnQueryInterface {
+ return new GeneratedColumnQueryPostgres();
+ }
+
+ convertFormulaToGeneratedColumn(
+ expression: string,
+ context: IFormulaConversionContext
+ ): IFormulaConversionResult {
+ try {
+ const generatedColumnQuery = this.generatedColumnQuery();
+ // Set the context with driver client information
+ const contextWithDriver = { ...context, driverClient: this.driver };
+ generatedColumnQuery.setContext(contextWithDriver);
+
+ const visitor = new GeneratedColumnSqlConversionVisitor(
+ this.knex,
+ generatedColumnQuery,
+ contextWithDriver
+ );
+
+ const sql = parseFormulaToSQL(expression, visitor);
+
+ return visitor.getResult(sql);
+ } catch (error) {
+ throw new Error(`Failed to convert formula: ${(error as Error).message}`);
+ }
+ }
+
+ selectQuery(): ISelectQueryInterface {
+ return new SelectQueryPostgres();
+ }
+
+ convertFormulaToSelectQuery(
+ expression: string,
+ context: ISelectFormulaConversionContext
+ ): IFieldSelectName {
+ try {
+ const selectQuery = this.selectQuery();
+
+ // Set the context with driver client information
+ const contextWithDriver = { ...context, driverClient: this.driver };
+ selectQuery.setContext(contextWithDriver);
+
+ const visitor = new SelectColumnSqlConversionVisitor(
+ this.knex,
+ selectQuery,
+ contextWithDriver
+ );
+
+ return parseFormulaToSQL(expression, visitor);
+ } catch (error) {
+ throw new Error(`Failed to convert formula: ${(error as Error).message}`);
+ }
+ }
+
+ generateDatabaseViewName(tableId: string): string {
+ return tableId + '_view';
+ }
+
+ createDatabaseView(
+ table: TableDomain,
+ qb: Knex.QueryBuilder,
+ options?: { materialized?: boolean }
+ ): string[] {
+ const viewName = this.generateDatabaseViewName(table.id);
+ if (options?.materialized) {
+ // Create MV and add unique index on __id to support concurrent refresh
+ const createMv = this.knex
+ .raw(`CREATE MATERIALIZED VIEW ?? AS ${qb.toQuery()}`, [viewName])
+ .toQuery();
+ const createIndex = `CREATE UNIQUE INDEX IF NOT EXISTS ${viewName}__id_uidx ON "${viewName}" ("__id")`;
+ return [createMv, createIndex];
+ }
+ return [this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery()];
+ }
+
+ recreateDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[] {
+ const oldName = this.generateDatabaseViewName(table.id);
+ const newName = `${oldName}_new`;
+ const stmts: string[] = [];
+ // Clean temp and conflicting indexes
+ stmts.push(`DROP INDEX IF EXISTS "${newName}__id_uidx"`);
+ stmts.push(`DROP INDEX IF EXISTS "${oldName}__id_uidx"`);
+ stmts.push(`DROP MATERIALIZED VIEW IF EXISTS "${newName}"`);
+ // Create empty MV and index, then initial non-concurrent populate
+ stmts.push(`CREATE MATERIALIZED VIEW "${newName}" AS ${qb.toQuery()} WITH NO DATA`);
+ stmts.push(`CREATE UNIQUE INDEX "${newName}__id_uidx" ON "${newName}" ("__id")`);
+ stmts.push(`REFRESH MATERIALIZED VIEW "${newName}"`);
+ // Swap
+ stmts.push(`DROP MATERIALIZED VIEW IF EXISTS "${oldName}"`);
+ stmts.push(`ALTER MATERIALIZED VIEW "${newName}" RENAME TO "${oldName}"`);
+ // Keep index name stable after swap
+ stmts.push(`ALTER INDEX "${newName}__id_uidx" RENAME TO "${oldName}__id_uidx"`);
+ // Ensure final MV has data (defensive refresh)
+ stmts.push(`REFRESH MATERIALIZED VIEW "${oldName}"`);
+ return stmts;
+ }
+
+ dropDatabaseView(tableId: string): string[] {
+ const viewName = this.generateDatabaseViewName(tableId);
+ // Try dropping both MV and normal VIEW to be safe
+ return [
+ this.knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ??`, [viewName]).toQuery(),
+ this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery(),
+ ];
+ }
+
+ refreshDatabaseView(tableId: string, options?: { concurrently?: boolean }): string {
+ const viewName = this.generateDatabaseViewName(tableId);
+ this.logger.debug(
+ 'refreshDatabaseView %s with concurrently %s',
+ viewName,
+ options?.concurrently
+ );
+ const concurrently = options?.concurrently ?? true;
+ if (concurrently) {
+ return `REFRESH MATERIALIZED VIEW CONCURRENTLY "${viewName}"`;
+ }
+ return `REFRESH MATERIALIZED VIEW "${viewName}"`;
+ }
+
+ createMaterializedView(table: TableDomain, qb: Knex.QueryBuilder): string {
+ const viewName = this.generateDatabaseViewName(table.id);
+ return this.knex.raw(`CREATE MATERIALIZED VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery();
+ }
+
+ dropMaterializedView(tableId: string): string {
+ const viewName = this.generateDatabaseViewName(tableId);
+ return this.knex.raw(`DROP MATERIALIZED VIEW IF EXISTS ??`, [viewName]).toQuery();
}
}
diff --git a/apps/nestjs-backend/src/db-provider/search-query/abstract.ts b/apps/nestjs-backend/src/db-provider/search-query/abstract.ts
new file mode 100644
index 0000000000..b4fb16755c
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/search-query/abstract.ts
@@ -0,0 +1,137 @@
+import type { TableIndex } from '@teable/openapi';
+import type { Knex } from 'knex';
+import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';
+import type { ISearchQueryConstructor } from './types';
+
+export abstract class SearchQueryAbstract {
+ static appendQueryBuilder(
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ SearchQuery: ISearchQueryConstructor,
+ originQueryBuilder: Knex.QueryBuilder,
+ searchFields: IFieldInstance[],
+ tableIndex: TableIndex[],
+ search: [string, string?, boolean?],
+ context?: IRecordQueryFilterContext
+ ) {
+ if (!search || !searchFields?.length) {
+ return originQueryBuilder;
+ }
+
+ searchFields.forEach((fIns) => {
+ const builder = new SearchQuery(originQueryBuilder, fIns, search, tableIndex, context);
+ builder.appendBuilder();
+ });
+
+ return originQueryBuilder;
+ }
+
+ static buildSearchCountQuery(
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ SearchQuery: ISearchQueryConstructor,
+ queryBuilder: Knex.QueryBuilder,
+ searchField: IFieldInstance[],
+ search: [string, string?, boolean?],
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext
+ ) {
+ const knexInstance = queryBuilder.client;
+
+ const conditions = searchField
+ .map((field) => {
+ const searchQueryBuilder = new SearchQuery(
+ queryBuilder,
+ field,
+ search,
+ tableIndex,
+ context
+ );
+ return searchQueryBuilder.getQuery();
+ })
+ .filter((cond): cond is Knex.Raw => Boolean(cond));
+
+ if (conditions.length === 0) {
+ queryBuilder.select(knexInstance.raw('0 as count'));
+ return queryBuilder;
+ }
+
+ const parts = conditions.map((cond) =>
+ knexInstance.raw('(CASE WHEN (?) THEN 1 ELSE 0 END)', [cond])
+ );
+
+ // Use nested raws to preserve bindings and avoid inlining values into SQL text.
+ queryBuilder.select(
+ knexInstance.raw(`COALESCE(SUM(${parts.map(() => '(?)').join(' + ')}), 0) as count`, parts)
+ );
+
+ return queryBuilder;
+ }
+
+ protected readonly fieldName: string;
+
+ constructor(
+ protected readonly originQueryBuilder: Knex.QueryBuilder,
+ protected readonly field: IFieldInstance,
+ protected readonly search: [string, string?, boolean?],
+ protected readonly tableIndex: TableIndex[],
+ protected readonly context?: IRecordQueryFilterContext
+ ) {
+ const { dbFieldName, id } = field;
+
+ const selection = context?.selectionMap.get(id);
+ if (selection !== undefined && selection !== null) {
+ this.fieldName = this.normalizeSelection(selection) ?? this.quoteIdentifier(dbFieldName);
+ } else {
+ this.fieldName = this.quoteIdentifier(dbFieldName);
+ }
+ }
+
+ protected abstract json(): Knex.Raw;
+
+ protected abstract text(): Knex.Raw;
+
+ protected abstract date(): Knex.Raw;
+
+ protected abstract number(): Knex.Raw;
+
+ protected abstract multipleNumber(): Knex.Raw;
+
+ protected abstract multipleDate(): Knex.Raw;
+
+ protected abstract multipleText(): Knex.Raw;
+
+ protected abstract multipleJson(): Knex.Raw;
+
+ abstract getSql(): string | null;
+
+ abstract getQuery(): Knex.Raw | null;
+
+ abstract appendBuilder(): Knex.QueryBuilder;
+
+ private normalizeSelection(selection: unknown): string | undefined {
+ if (typeof selection === 'string') {
+ return selection;
+ }
+ if (selection && typeof (selection as Knex.Raw).toQuery === 'function') {
+ return (selection as Knex.Raw).toQuery();
+ }
+ if (selection && typeof (selection as Knex.Raw).toSQL === 'function') {
+ const { sql } = (selection as Knex.Raw).toSQL();
+ if (sql) {
+ return sql;
+ }
+ }
+ return undefined;
+ }
+
+ private quoteIdentifier(identifier: string): string {
+ if (!identifier) {
+ return identifier;
+ }
+ if (identifier.startsWith('"') && identifier.endsWith('"')) {
+ return identifier;
+ }
+ const escaped = identifier.replace(/"/g, '""');
+ return `"${escaped}"`;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/search-query/get-offset.ts b/apps/nestjs-backend/src/db-provider/search-query/get-offset.ts
new file mode 100644
index 0000000000..645e720477
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/search-query/get-offset.ts
@@ -0,0 +1,9 @@
+import dayjs from 'dayjs';
+import 'dayjs/plugin/utc';
+
+export function getOffset(timeZone: string) {
+ const offsetMinutes = dayjs().tz(timeZone).utcOffset();
+
+ const offsetHours = offsetMinutes / 60;
+ return offsetHours >= 0 ? `+${offsetHours}` : `${offsetHours}`;
+}
diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts
new file mode 100644
index 0000000000..7da90c63b8
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.postgres.ts
@@ -0,0 +1,249 @@
+/* eslint-disable regexp/no-unused-capturing-group */
+/* eslint-disable sonarjs/no-duplicate-string */
+import { assertNever, CellValueType, FieldType } from '@teable/core';
+import type { IFieldInstance } from '../../features/field/model/factory';
+
+import { IndexBuilderAbstract } from '../index-query/index-abstract-builder';
+
+interface IPgIndex {
+ schemaname: string;
+ tablename: string;
+ indexname: string;
+ tablespace: string;
+ indexdef: string;
+}
+
+const unSupportCellValueType = [CellValueType.DateTime, CellValueType.Boolean];
+
+export class FieldFormatter {
+ static getSearchableExpression(field: IFieldInstance, isArray = false): string | null {
+ const { cellValueType, dbFieldName, options, isStructuredCellValue } = field;
+
+ // base expression
+ const baseExpression = (() => {
+ switch (cellValueType) {
+ case CellValueType.Number: {
+ const precision =
+ (options as { formatting?: { precision?: number } })?.formatting?.precision ?? 0;
+ return `ROUND(value::numeric, ${precision})::text`;
+ }
+ case CellValueType.DateTime: {
+ // date type not support full text search
+ return null;
+ }
+ case CellValueType.Boolean: {
+ // date type not support full text search
+ return null;
+ }
+ case CellValueType.String: {
+ if (isStructuredCellValue) {
+ return `"${dbFieldName}"::jsonb #>> '{title}'`;
+ }
+ if (field.type === FieldType.LongText) {
+ // chr(13) is carriage return, chr(10) is line feed, chr(9) is tab
+ return `REPLACE(REPLACE(REPLACE(value, CHR(13), ' '::text), CHR(10), ' '::text), CHR(9), ' '::text)`;
+ } else {
+ return `value`;
+ }
+ }
+ default:
+ assertNever(cellValueType);
+ }
+ })();
+
+ if (baseExpression === null) {
+ return null;
+ }
+
+ // handle array type
+ // gin cannot handle any sub-query, so we need to use array_to_string to convert array to stringZ
+ if (isArray) {
+ return `"${dbFieldName}"::text`;
+ }
+
+ // handle single value type
+ return baseExpression.replace(/value/g, `"${dbFieldName}"`);
+ }
+
+ // expression for generating index
+ static getIndexExpression(field: IFieldInstance): string | null {
+ return this.getSearchableExpression(field, field.isMultipleCellValue);
+ }
+}
+
+export class IndexBuilderPostgres extends IndexBuilderAbstract {
+ static PG_MAX_INDEX_LEN = 63;
+ static DELIMITER_LEN = 3;
+
+ private getIndexPrefix() {
+ return `idx_trgm`;
+ }
+
+ private getIndexName(table: string, field: Pick): string {
+ const { dbFieldName, id } = field;
+ const prefix = this.getIndexPrefix();
+ const maxTableDbNameLen =
+ IndexBuilderPostgres.PG_MAX_INDEX_LEN -
+ id.length -
+ this.getIndexPrefix().length -
+ IndexBuilderPostgres.DELIMITER_LEN;
+ const tableDbNameLen = maxTableDbNameLen < table.length ? maxTableDbNameLen : table.length;
+ // 3 is space character
+ const dbFieldNameLen =
+ maxTableDbNameLen < table.length
+ ? 0
+ : IndexBuilderPostgres.PG_MAX_INDEX_LEN -
+ id.length -
+ this.getIndexPrefix().length -
+ tableDbNameLen -
+ IndexBuilderPostgres.DELIMITER_LEN;
+ const abbDbFieldName = dbFieldName.slice(0, dbFieldNameLen);
+ return `${prefix}_${table.slice(0, tableDbNameLen)}_${abbDbFieldName}_${id}`;
+ }
+
+ private getSearchFactor() {
+ return this.getIndexPrefix();
+ }
+
+ createSingleIndexSql(dbTableName: string, field: IFieldInstance): string | null {
+ const [schema, table] = dbTableName.split('.');
+ const indexName = this.getIndexName(table, field);
+ const expression = FieldFormatter.getIndexExpression(field);
+ if (expression === null) {
+ return null;
+ }
+
+ return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${schema}"."${table}" USING gin ((${expression}) gin_trgm_ops)`;
+ }
+
+ getDropIndexSql(dbTableName: string): string {
+ const [schema, table] = dbTableName.split('.');
+ const searchFactor = this.getSearchFactor();
+ return `
+ DO $$
+ DECLARE
+ _index record;
+ BEGIN
+ FOR _index IN
+ SELECT indexname
+ FROM pg_indexes
+ WHERE schemaname = '${schema}'
+ AND tablename = '${table}'
+ AND indexname LIKE '${searchFactor}%'
+ LOOP
+ EXECUTE 'DROP INDEX IF EXISTS "' || '${schema}' || '"."' || _index.indexname || '"';
+ END LOOP;
+ END $$;
+ `;
+ }
+
+ getCreateIndexSql(dbTableName: string, searchFields: IFieldInstance[]): string[] {
+ const fieldSql = searchFields
+ .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType))
+ .map((field) => {
+ const expression = FieldFormatter.getIndexExpression(field);
+ return expression ? this.createSingleIndexSql(dbTableName, field) : null;
+ })
+ .filter((sql): sql is string => sql !== null);
+
+ fieldSql.unshift(`CREATE EXTENSION IF NOT EXISTS pg_trgm;`);
+ return fieldSql;
+ }
+
+ getExistTableIndexSql(dbTableName: string): string {
+ const [schema, table] = dbTableName.split('.');
+ const searchFactor = this.getSearchFactor();
+ return `
+ SELECT EXISTS (
+ SELECT 1
+ FROM pg_indexes
+ WHERE schemaname = '${schema}'
+ AND tablename = '${table}'
+ AND indexname LIKE '${searchFactor}%'
+ )`;
+ }
+
+ getDeleteSingleIndexSql(dbTableName: string, field: IFieldInstance): string {
+ const [schema, table] = dbTableName.split('.');
+ const indexName = this.getIndexName(table, field);
+
+ return `DROP INDEX IF EXISTS "${schema}"."${indexName}"`;
+ }
+
+ getUpdateSingleIndexNameSql(
+ dbTableName: string,
+ oldField: Pick,
+ newField: Pick
+ ): string {
+ const [schema, table] = dbTableName.split('.');
+ const oldIndexName = this.getIndexName(table, oldField);
+ const newIndexName = this.getIndexName(table, newField);
+
+ return `
+ ALTER INDEX IF EXISTS "${schema}"."${oldIndexName}"
+ RENAME TO "${newIndexName}"
+ `;
+ }
+
+ getIndexInfoSql(dbTableName: string): string {
+ const [, table] = dbTableName.split('.');
+ const searchFactor = this.getSearchFactor();
+ return `
+ SELECT * FROM pg_indexes
+ WHERE tablename = '${table}'
+ AND indexname like '${searchFactor}%'`;
+ }
+
+ getAbnormalIndex(dbTableName: string, fields: IFieldInstance[], existingIndex: IPgIndex[]) {
+ const [, table] = dbTableName.split('.');
+ const expectExistIndex = fields
+ .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType))
+ .map((field) => {
+ return this.getIndexName(table, field);
+ });
+
+ // 1: find the lack or redundant index
+ const lackingIndex = expectExistIndex.filter(
+ (idxName) => !existingIndex.map((idx) => idx.indexname).includes(idxName)
+ );
+ const redundantIndex = existingIndex
+ .map((idx) => idx.indexname)
+ .filter((idxName) => !expectExistIndex.includes(idxName));
+
+ const diffIndex = [...new Set([...redundantIndex, ...lackingIndex])];
+
+ if (diffIndex.length) {
+ return diffIndex.map((idxName) => ({ indexName: idxName }));
+ }
+
+ // 2: find the abnormal index definition
+ const expectIndexDef = fields
+ .filter(({ cellValueType }) => !unSupportCellValueType.includes(cellValueType))
+ .map((f) => {
+ return {
+ indexName: this.getIndexName(table, f),
+ indexDef: this.createSingleIndexSql(dbTableName, f) as string,
+ };
+ });
+
+ return expectIndexDef
+ .filter(({ indexDef }) => {
+ const existIndex = existingIndex.map((idx) =>
+ idx.indexdef
+ .toLowerCase()
+ .replace(/[()\s"']/g, '')
+ .replace(/::(jsonb|text\[\]|text)/g, '')
+ );
+ return !existIndex.includes(
+ indexDef
+ .toLowerCase()
+ .replace(/[()\s"']/g, '')
+ .replace(/::(jsonb|text\[\]|text)/g, '')
+ .replace(/ifnotexists/g, '')
+ );
+ })
+ .map(({ indexName }) => ({
+ indexName,
+ }));
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.sqlite.ts b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.sqlite.ts
new file mode 100644
index 0000000000..b4cd260d80
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/search-query/search-index-builder.sqlite.ts
@@ -0,0 +1,115 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import { CellValueType } from '@teable/core';
+import type { IGetAbnormalVo } from '@teable/openapi';
+import type { IFieldInstance } from '../../features/field/model/factory';
+import { IndexBuilderAbstract } from '../index-query/index-abstract-builder';
+import type { ISearchCellValueType } from './types';
+
+type ISqliteIndex = Record;
+
+export class FieldFormatter {
+ static getSearchableExpression(field: IFieldInstance, isArray = false): string {
+ const { cellValueType, dbFieldName, options, isStructuredCellValue } = field;
+
+ // base expression
+ const baseExpression = (() => {
+ switch (cellValueType as ISearchCellValueType) {
+ case CellValueType.Number: {
+ const precision =
+ (options as { formatting?: { precision?: number } })?.formatting?.precision ?? 0;
+ return `ROUND(CAST(value AS REAL), ${precision})`;
+ }
+ case CellValueType.DateTime: {
+ // SQLite doesn't support timezone conversion directly
+ // We'll format the date in a basic format
+ return `strftime('%Y-%m-%d %H:%M', value)`;
+ }
+ case CellValueType.String: {
+ if (isStructuredCellValue) {
+ return `json_extract(value, '$.title')`;
+ }
+ return 'CAST(value AS TEXT)';
+ }
+ default:
+ return 'CAST(value AS TEXT)';
+ }
+ })();
+
+ // handle array type
+ if (isArray) {
+ return `(
+ WITH RECURSIVE split(word, str) AS (
+ SELECT '', json_extract(${dbFieldName}, '$') || ','
+ UNION ALL
+ SELECT
+ substr(str, 0, instr(str, ',')),
+ substr(str, instr(str, ',') + 1)
+ FROM split WHERE str != ''
+ )
+ SELECT group_concat(${baseExpression.replace(/value/g, 'word')}, ', ')
+ FROM split WHERE word != ''
+ )`;
+ }
+
+ // handle single value type
+ return baseExpression.replace(/value/g, dbFieldName);
+ }
+
+ // expression for generating index
+ static getIndexExpression(field: IFieldInstance): string {
+ return this.getSearchableExpression(field, field.isMultipleCellValue);
+ }
+}
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const NO_OPERATION_SQL = '/* no operation */';
+
+export class IndexBuilderSqlite extends IndexBuilderAbstract {
+ private getIndexName(table: string, dbFieldName: string): string {
+ return `idx_trgm_${table}_${dbFieldName}`;
+ }
+
+ createSingleIndexSql(dbTableName: string, field: IFieldInstance): string {
+ return NO_OPERATION_SQL;
+ }
+
+ getDropIndexSql(dbTableName: string): string {
+ return `SELECT 'DROP TABLE IF EXISTS "' || name || '";'
+ FROM sqlite_master
+ WHERE type='table'
+ AND name LIKE 'idx_fts_${dbTableName}_%'`;
+ }
+
+ getCreateIndexSql(dbTableName: string, searchFields: IFieldInstance[]): string[] {
+ return searchFields.map((field) => this.createSingleIndexSql(dbTableName, field));
+ }
+
+ getExistTableIndexSql(dbTableName: string): string {
+ return `SELECT EXISTS (
+ SELECT 1
+ FROM sqlite_master
+ WHERE type='table'
+ AND name LIKE 'idx_fts_${dbTableName}_%'
+ )`;
+ }
+
+ getDeleteSingleIndexSql(dbTableName: string, field: IFieldInstance): string {
+ return NO_OPERATION_SQL;
+ }
+
+ getUpdateSingleIndexNameSql(
+ dbTableName: string,
+ oldField: IFieldInstance,
+ newField: IFieldInstance
+ ): string {
+ return NO_OPERATION_SQL;
+ }
+
+ getIndexInfoSql(dbTableName: string): string {
+ return NO_OPERATION_SQL;
+ }
+
+ getAbnormalIndex(dbTableName: string, fields: IFieldInstance[], existingIndex: ISqliteIndex[]) {
+ return [] as IGetAbnormalVo;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts
new file mode 100644
index 0000000000..35fe4118c6
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/search-query/search-query.postgres.ts
@@ -0,0 +1,406 @@
+import type { IDateFieldOptions } from '@teable/core';
+import { CellValueType, FieldType } from '@teable/core';
+import type { ISearchIndexByQueryRo } from '@teable/openapi';
+import { TableIndex } from '@teable/openapi';
+import { type Knex } from 'knex';
+import { get } from 'lodash';
+import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';
+import { escapePostgresRegex } from '../../utils/postgres-regex-escape';
+import { escapeLikeWildcards } from '../../utils/sql-like-escape';
+import { SearchQueryAbstract } from './abstract';
+import { FieldFormatter } from './search-index-builder.postgres';
+import type { ISearchCellValueType } from './types';
+
+export class SearchQueryPostgres extends SearchQueryAbstract {
+ protected knex: Knex.Client;
+ constructor(
+ protected originQueryBuilder: Knex.QueryBuilder,
+ protected field: IFieldInstance,
+ protected search: [string, string?, boolean?],
+ protected tableIndex: TableIndex[],
+ protected context?: IRecordQueryFilterContext
+ ) {
+ super(originQueryBuilder, field, search, tableIndex, context);
+ this.knex = originQueryBuilder.client;
+ }
+
+ appendBuilder() {
+ const { originQueryBuilder } = this;
+ const condition = this.getQuery();
+ condition && this.originQueryBuilder.orWhereRaw(condition);
+ return originQueryBuilder;
+ }
+
+ getSql(): string | null {
+ const condition = this.getQuery();
+ return condition ? condition.toSQL().sql : null;
+ }
+
+ getQuery() {
+ const { field, tableIndex } = this;
+ const { isMultipleCellValue } = field;
+
+ if (tableIndex.includes(TableIndex.search)) {
+ return this.getSearchQueryWithIndex();
+ } else {
+ return isMultipleCellValue ? this.getMultipleCellTypeQuery() : this.getSingleCellTypeQuery();
+ }
+ }
+
+ protected getSearchQueryWithIndex() {
+ const { search, knex, field } = this;
+ const { isMultipleCellValue } = field;
+ const isSearchAllFields = !search[1];
+ if (isSearchAllFields) {
+ const searchValue = search[0];
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const expression = FieldFormatter.getSearchableExpression(field, isMultipleCellValue);
+ return expression
+ ? knex.raw(`(${expression}) ILIKE ? ESCAPE '\\'`, [`%${escapedSearchValue}%`])
+ : null;
+ } else {
+ return isMultipleCellValue ? this.getMultipleCellTypeQuery() : this.getSingleCellTypeQuery();
+ }
+ }
+
+ protected getSingleCellTypeQuery() {
+ const { field } = this;
+ const { isStructuredCellValue, cellValueType } = field;
+ switch (cellValueType as ISearchCellValueType) {
+ case CellValueType.String: {
+ if (isStructuredCellValue) {
+ return this.json();
+ } else {
+ return this.text();
+ }
+ }
+ case CellValueType.DateTime: {
+ return this.date();
+ }
+ case CellValueType.Number: {
+ return this.number();
+ }
+ default:
+ return this.text();
+ }
+ }
+
+ protected getMultipleCellTypeQuery() {
+ const { field } = this;
+ const { isStructuredCellValue, cellValueType } = field;
+ switch (cellValueType as ISearchCellValueType) {
+ case CellValueType.String: {
+ if (isStructuredCellValue) {
+ return this.multipleJson();
+ } else {
+ return this.multipleText();
+ }
+ }
+ case CellValueType.DateTime: {
+ return this.multipleDate();
+ }
+ case CellValueType.Number: {
+ return this.multipleNumber();
+ }
+ default:
+ return this.multipleText();
+ }
+ }
+
+ protected text() {
+ const { search, knex } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+
+ if (this.field.type === FieldType.LongText) {
+ return knex.raw(
+ // chr(13) is carriage return, chr(10) is line feed, chr(9) is tab
+ `REPLACE(REPLACE(REPLACE(${this.fieldName}, CHR(13), ' '::text), CHR(10), ' '::text), CHR(9), ' '::text) ILIKE ? ESCAPE '\\'`,
+ [`%${escapedSearchValue}%`]
+ );
+ } else {
+ return knex.raw(`${this.fieldName} ILIKE ? ESCAPE '\\'`, [`%${escapedSearchValue}%`]);
+ }
+ }
+
+ protected number() {
+ const { search, knex } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;
+ return knex.raw(`ROUND(${this.fieldName}::numeric, ?::int)::text ILIKE ? ESCAPE '\\'`, [
+ precision,
+ `%${escapedSearchValue}%`,
+ ]);
+ }
+
+ protected date() {
+ const {
+ search,
+ knex,
+ field: { options },
+ } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const timeZone = (options as IDateFieldOptions).formatting.timeZone;
+ return knex.raw(
+ `TO_CHAR(TIMEZONE(?, ${this.fieldName}), 'YYYY-MM-DD HH24:MI') ILIKE ? ESCAPE '\\'`,
+ [timeZone, `%${escapedSearchValue}%`]
+ );
+ }
+
+ protected json() {
+ const { search, knex } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ return knex.raw(`(${this.fieldName})::jsonb #>> '{title}' ILIKE ? ESCAPE '\\'`, [
+ `%${escapedSearchValue}%`,
+ ]);
+ }
+
+ protected multipleText() {
+ const { search, knex } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapePostgresRegex(searchValue);
+ return knex.raw(
+ `
+ EXISTS (
+ SELECT 1
+ FROM (
+ SELECT string_agg(elem::text, ', ') as aggregated
+ FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem
+ ) as sub
+ WHERE sub.aggregated ~* ?
+ )
+ `,
+ [escapedSearchValue]
+ );
+ }
+
+ protected multipleNumber() {
+ const { search, knex } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;
+ return knex.raw(
+ `
+ EXISTS (
+ SELECT 1 FROM (
+ SELECT string_agg(ROUND(elem::numeric, ?::int)::text, ', ') as aggregated
+ FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem
+ ) as sub
+ WHERE sub.aggregated ILIKE ? ESCAPE '\\'
+ )
+ `,
+ [precision, `%${escapedSearchValue}%`]
+ );
+ }
+
+ protected multipleDate() {
+ const { search, knex } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone;
+ return knex.raw(
+ `
+ EXISTS (
+ SELECT 1 FROM (
+ SELECT string_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), 'YYYY-MM-DD HH24:MI'), ', ') as aggregated
+ FROM jsonb_array_elements_text(${this.fieldName}::jsonb) as elem
+ ) as sub
+ WHERE sub.aggregated ILIKE ? ESCAPE '\\'
+ )
+ `,
+ [timeZone, `%${escapedSearchValue}%`]
+ );
+ }
+
+ protected multipleJson() {
+ const { search, knex } = this;
+ const searchValue = search[0];
+ const escapedSearchValue = escapePostgresRegex(searchValue);
+ return knex.raw(
+ `
+ EXISTS (
+ WITH RECURSIVE f(e) AS (
+ SELECT ${this.fieldName}::jsonb
+ UNION ALL
+ SELECT jsonb_array_elements(f.e)
+ FROM f
+ WHERE jsonb_typeof(f.e) = 'array'
+ )
+ SELECT 1 FROM (
+ SELECT string_agg((e->>'title')::text, ', ') as aggregated
+ FROM f
+ WHERE jsonb_typeof(e) <> 'array'
+ ) as sub
+ WHERE sub.aggregated ~* ?
+ )
+ `,
+ [escapedSearchValue]
+ );
+ }
+}
+
+export class SearchQueryPostgresBuilder {
+ constructor(
+ public queryBuilder: Knex.QueryBuilder,
+ public dbTableName: string,
+ public searchFields: IFieldInstance[],
+ public searchIndexRo: ISearchIndexByQueryRo,
+ public tableIndex: TableIndex[],
+ public context?: IRecordQueryFilterContext,
+ public baseSortIndex?: string,
+ public setFilterQuery?: (qb: Knex.QueryBuilder) => void,
+ public setSortQuery?: (qb: Knex.QueryBuilder) => void
+ ) {
+ this.queryBuilder = queryBuilder;
+ this.dbTableName = dbTableName;
+ this.searchFields = searchFields;
+ this.baseSortIndex = baseSortIndex;
+ this.searchIndexRo = searchIndexRo;
+ this.setFilterQuery = setFilterQuery;
+ this.setSortQuery = setSortQuery;
+ this.tableIndex = tableIndex;
+ this.context = context;
+ }
+
+ private getSearchConditions() {
+ const { queryBuilder, searchIndexRo, searchFields, tableIndex, context } = this;
+ const { search } = searchIndexRo;
+
+ if (!search || !searchFields?.length) {
+ return [] as Array<{ field: IFieldInstance; condition: Knex.Raw }>;
+ }
+
+ return searchFields
+ .map((field) => {
+ const searchQueryBuilder = new SearchQueryPostgres(
+ queryBuilder,
+ field,
+ search,
+ tableIndex,
+ context
+ );
+ const condition = searchQueryBuilder.getQuery();
+ return condition ? { field, condition } : undefined;
+ })
+ .filter((item): item is { field: IFieldInstance; condition: Knex.Raw } => Boolean(item));
+ }
+
+ getCaseWhenSqlBy() {
+ const { queryBuilder, searchIndexRo, context } = this;
+ const { search } = searchIndexRo;
+ const isSearchAllFields = !search?.[1];
+ const knexInstance = queryBuilder.client;
+ const conditions = this.getSearchConditions();
+
+ return conditions
+ .filter(({ field }) => {
+ // global search does not support date time and checkbox
+ if (
+ isSearchAllFields &&
+ [CellValueType.DateTime, CellValueType.Boolean].includes(field.cellValueType)
+ ) {
+ return false;
+ }
+ return true;
+ })
+ .map(({ field, condition }) => {
+ // Get the correct field name using the same logic as in SearchQueryAbstract
+ const selection = context?.selectionMap.get(field.id);
+ const fieldName = selection ? (selection as string) : field.dbFieldName;
+
+ return knexInstance.raw('CASE WHEN (?) THEN ? END', [condition, fieldName]);
+ });
+ }
+
+ getSearchIndexQuery() {
+ const {
+ queryBuilder,
+ dbTableName,
+ searchFields: searchField,
+ searchIndexRo,
+ setFilterQuery,
+ setSortQuery,
+ baseSortIndex,
+ } = this;
+
+ const { search, groupBy, orderBy, take, skip } = searchIndexRo;
+ const knexInstance = queryBuilder.client;
+
+ if (!search || !searchField.length) {
+ return queryBuilder;
+ }
+
+ const searchConditions = this.getSearchConditions();
+ const caseWhenConditions = this.getCaseWhenSqlBy();
+
+ queryBuilder.with('search_hit_row', (qb) => {
+ qb.select('*');
+
+ qb.from(dbTableName);
+
+ qb.where((subQb) => {
+ subQb.where((orWhere) => {
+ searchConditions.forEach(({ condition }) => {
+ orWhere.orWhereRaw(condition);
+ });
+ });
+ if (this.searchIndexRo.filter && setFilterQuery) {
+ subQb.andWhere((andQb) => {
+ setFilterQuery?.(andQb);
+ });
+ }
+ });
+
+ if (orderBy?.length || groupBy?.length) {
+ setSortQuery?.(qb);
+ }
+
+ take && qb.limit(take);
+
+ qb.offset(skip ?? 0);
+
+ baseSortIndex && qb.orderBy(baseSortIndex, 'asc');
+ });
+
+ queryBuilder.with('search_field_union_table', (qb) => {
+ qb.select('__id').select(
+ knexInstance.raw(
+ `array_remove(ARRAY [${caseWhenConditions.map(() => '(?)').join(', ')}], NULL) as matched_columns`,
+ caseWhenConditions
+ )
+ );
+
+ qb.from('search_hit_row');
+ });
+
+ queryBuilder
+ .select('__id', 'matched_column')
+ .select(
+ knexInstance.raw(
+ `CASE
+ ${searchField
+ .map((field) => {
+ // Get the correct field name using the same logic as in SearchQueryAbstract
+ const selection = this.context?.selectionMap.get(field.id);
+ const fieldName = selection ? (selection as string) : field.dbFieldName;
+ return knexInstance.raw(`WHEN matched_column = '${fieldName}' THEN ?`, [field.id]);
+ })
+ .join(' ')}
+ END AS "fieldId"`
+ )
+ )
+ .fromRaw(
+ `
+ "search_field_union_table",
+ LATERAL unnest(matched_columns) AS matched_column
+ `
+ )
+ .whereRaw(`array_length(matched_columns, 1) > 0`);
+
+ return queryBuilder;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/search-query/search-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/search-query/search-query.sqlite.ts
new file mode 100644
index 0000000000..1eff34d096
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/search-query/search-query.sqlite.ts
@@ -0,0 +1,368 @@
+import { CellValueType, type IDateFieldOptions } from '@teable/core';
+import type { ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';
+import type { Knex } from 'knex';
+import { get } from 'lodash';
+import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';
+import { escapeLikeWildcards } from '../../utils/sql-like-escape';
+import { SearchQueryAbstract } from './abstract';
+import { getOffset } from './get-offset';
+import type { ISearchCellValueType } from './types';
+
+export class SearchQuerySqlite extends SearchQueryAbstract {
+ protected knex: Knex.Client;
+ constructor(
+ protected originQueryBuilder: Knex.QueryBuilder,
+ protected field: IFieldInstance,
+ protected search: [string, string?, boolean?],
+ protected tableIndex: TableIndex[],
+ protected context?: IRecordQueryFilterContext
+ ) {
+ super(originQueryBuilder, field, search, tableIndex, context);
+ this.knex = originQueryBuilder.client;
+ }
+
+ appendBuilder() {
+ const { originQueryBuilder } = this;
+ const condition = this.getQuery();
+ condition && this.originQueryBuilder.orWhereRaw(condition);
+ return originQueryBuilder;
+ }
+
+ getSql(): string | null {
+ return this.getQuery().toSQL().sql;
+ }
+
+ getQuery() {
+ const { field } = this;
+ const { isMultipleCellValue } = field;
+
+ return isMultipleCellValue ? this.getMultipleCellTypeQuery() : this.getSingleCellTypeQuery();
+ }
+
+ protected getSearchQueryWithIndex() {
+ return this.originQueryBuilder;
+ }
+
+ protected getMultipleCellTypeQuery() {
+ const { field } = this;
+ const { isStructuredCellValue, cellValueType } = field;
+ switch (cellValueType as ISearchCellValueType) {
+ case CellValueType.String: {
+ if (isStructuredCellValue) {
+ return this.multipleJson();
+ } else {
+ return this.multipleText();
+ }
+ }
+ case CellValueType.DateTime: {
+ return this.multipleDate();
+ }
+ case CellValueType.Number: {
+ return this.multipleNumber();
+ }
+ default:
+ return this.multipleText();
+ }
+ }
+
+ protected getSingleCellTypeQuery() {
+ const { field } = this;
+ const { isStructuredCellValue, cellValueType } = field;
+ switch (cellValueType as ISearchCellValueType) {
+ case CellValueType.String: {
+ if (isStructuredCellValue) {
+ return this.json();
+ } else {
+ return this.text();
+ }
+ }
+ case CellValueType.DateTime: {
+ return this.date();
+ }
+ case CellValueType.Number: {
+ return this.number();
+ }
+ default:
+ return this.text();
+ }
+ }
+
+ protected text() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ return knex.raw(
+ `REPLACE(REPLACE(REPLACE(${this.fieldName}, CHAR(13), ' '), CHAR(10), ' '), CHAR(9), ' ') LIKE ? ESCAPE '\\'`,
+ [`%${escapedSearchValue}%`]
+ );
+ }
+
+ protected json() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ return knex.raw(`json_extract(${this.fieldName}, '$.title') LIKE ? ESCAPE '\\'`, [
+ `%${escapedSearchValue}%`,
+ ]);
+ }
+
+ protected date() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone;
+ return knex.raw(`DATETIME(${this.fieldName}, ?) LIKE ? ESCAPE '\\'`, [
+ `${getOffset(timeZone)} hour`,
+ `%${escapedSearchValue}%`,
+ ]);
+ }
+
+ protected number() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;
+ return knex.raw(`ROUND(${this.fieldName}, ?) LIKE ? ESCAPE '\\'`, [
+ precision,
+ `%${escapedSearchValue}%`,
+ ]);
+ }
+
+ protected multipleText() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ return knex.raw(
+ `
+ EXISTS (
+ SELECT 1 FROM (
+ SELECT group_concat(je.value, ', ') as aggregated
+ FROM json_each(${this.fieldName}) as je
+ WHERE je.key != 'title'
+ )
+ WHERE aggregated LIKE ? ESCAPE '\\'
+ )
+ `,
+ [`%${escapedSearchValue}%`]
+ );
+ }
+
+ protected multipleJson() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ return knex.raw(
+ `
+ EXISTS (
+ SELECT 1 FROM (
+ SELECT group_concat(json_extract(je.value, '$.title'), ', ') as aggregated
+ FROM json_each(${this.fieldName}) as je
+ )
+ WHERE aggregated LIKE ? ESCAPE '\\'
+ )
+ `,
+ [`%${escapedSearchValue}%`]
+ );
+ }
+
+ protected multipleNumber() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const precision = get(this.field, ['options', 'formatting', 'precision']) ?? 0;
+ return knex.raw(
+ `
+ EXISTS (
+ SELECT 1 FROM (
+ SELECT group_concat(ROUND(je.value, ?), ', ') as aggregated
+ FROM json_each(${this.fieldName}) as je
+ )
+ WHERE aggregated LIKE ? ESCAPE '\\'
+ )
+ `,
+ [precision, `%${escapedSearchValue}%`]
+ );
+ }
+
+ protected multipleDate() {
+ const { search, knex } = this;
+ const [searchValue] = search;
+ const escapedSearchValue = escapeLikeWildcards(searchValue);
+ const timeZone = (this.field.options as IDateFieldOptions).formatting.timeZone;
+ return knex.raw(
+ `
+ EXISTS (
+ SELECT 1 FROM (
+ SELECT group_concat(DATETIME(je.value, ?), ', ') as aggregated
+ FROM json_each(${this.fieldName}) as je
+ )
+ WHERE aggregated LIKE ? ESCAPE '\\'
+ )
+ `,
+ [`${getOffset(timeZone)} hour`, `%${escapedSearchValue}%`]
+ );
+ }
+}
+
+export class SearchQuerySqliteBuilder {
+ constructor(
+ public queryBuilder: Knex.QueryBuilder,
+ public dbTableName: string,
+ public searchField: IFieldInstance[],
+ public searchIndexRo: ISearchIndexByQueryRo,
+ public tableIndex: TableIndex[],
+ public context?: IRecordQueryFilterContext,
+ public baseSortIndex?: string,
+ public setFilterQuery?: (qb: Knex.QueryBuilder) => void,
+ public setSortQuery?: (qb: Knex.QueryBuilder) => void
+ ) {
+ this.queryBuilder = queryBuilder;
+ this.dbTableName = dbTableName;
+ this.searchField = searchField;
+ this.baseSortIndex = baseSortIndex;
+ this.searchIndexRo = searchIndexRo;
+ this.setFilterQuery = setFilterQuery;
+ this.setSortQuery = setSortQuery;
+ this.context = context;
+ }
+
+ private getSearchConditions() {
+ const { queryBuilder, searchIndexRo, searchField, tableIndex, context } = this;
+ const { search } = searchIndexRo;
+
+ if (!search || !searchField?.length) {
+ return [] as Array<{ field: IFieldInstance; condition: Knex.Raw }>;
+ }
+
+ return searchField.map((field) => {
+ const searchQueryBuilder = new SearchQuerySqlite(
+ queryBuilder,
+ field,
+ search,
+ tableIndex,
+ context
+ );
+ return { field, condition: searchQueryBuilder.getQuery() };
+ });
+ }
+
+ getSearchIndexQuery() {
+ const {
+ queryBuilder,
+ searchIndexRo,
+ dbTableName,
+ searchField,
+ baseSortIndex,
+ setFilterQuery,
+ setSortQuery,
+ } = this;
+ const { search, filter, orderBy, groupBy, skip, take } = searchIndexRo;
+ const knexInstance = queryBuilder.client;
+
+ if (!search || !searchField?.length) {
+ return queryBuilder;
+ }
+
+ const searchConditions = this.getSearchConditions();
+
+ queryBuilder.with('search_hit_row', (qb) => {
+ qb.select('*');
+
+ qb.from(dbTableName);
+
+ qb.where((subQb) => {
+ subQb.where((orWhere) => {
+ searchConditions.forEach(({ condition }) => {
+ orWhere.orWhereRaw(condition);
+ });
+ });
+ if (this.searchIndexRo.filter && setFilterQuery) {
+ subQb.andWhere((andQb) => {
+ setFilterQuery?.(andQb);
+ });
+ }
+ });
+
+ if (orderBy?.length || groupBy?.length) {
+ setSortQuery?.(qb);
+ }
+
+ take && qb.limit(take);
+
+ qb.offset(skip ?? 0);
+
+ baseSortIndex && qb.orderBy(baseSortIndex, 'asc');
+ });
+
+ queryBuilder.with('search_field_union_table', (qb) => {
+ for (let index = 0; index < searchConditions.length; index++) {
+ const { field, condition } = searchConditions[index];
+
+ // Get the correct field name using the same logic as in SearchQueryAbstract
+ const selection = this.context?.selectionMap.get(field.id);
+ const fieldName = selection ? (selection as string) : field.dbFieldName;
+
+ // boolean field or new field which does not support search should be skipped
+ if (!fieldName) {
+ continue;
+ }
+
+ if (index === 0) {
+ qb.select('*', knexInstance.raw(`? as matched_column`, [fieldName]))
+ .whereRaw(condition)
+ .from('search_hit_row');
+ } else {
+ qb.unionAll(function () {
+ this.select('*', knexInstance.raw(`? as matched_column`, [fieldName]))
+ .whereRaw(condition)
+ .from('search_hit_row');
+ });
+ }
+ }
+ });
+
+ queryBuilder
+ .select('__id', '__auto_number', 'matched_column')
+ .select(
+ knexInstance.raw(
+ `CASE
+ ${searchField
+ .map((field) => {
+ // Get the correct field name using the same logic as in SearchQueryAbstract
+ const selection = this.context?.selectionMap.get(field.id);
+ const fieldName = selection ? (selection as string) : field.dbFieldName;
+ return `WHEN matched_column = '${fieldName}' THEN '${field.id}'`;
+ })
+ .join(' ')}
+ END AS "fieldId"`
+ )
+ )
+ .from('search_field_union_table');
+
+ if (orderBy?.length || groupBy?.length) {
+ setSortQuery?.(queryBuilder);
+ }
+
+ if (filter) {
+ setFilterQuery?.(queryBuilder);
+ }
+
+ baseSortIndex && queryBuilder.orderBy(baseSortIndex, 'asc');
+
+ const cases = searchField.map((field, index) => {
+ // Get the correct field name using the same logic as in SearchQueryAbstract
+ const selection = this.context?.selectionMap.get(field.id);
+ const fieldName = selection ? (selection as string) : field.dbFieldName;
+
+ return knexInstance.raw(`CASE WHEN ?? = ? THEN ? END`, [
+ 'matched_column',
+ fieldName,
+ index + 1,
+ ]);
+ });
+ cases.length && queryBuilder.orderByRaw(cases.join(','));
+
+ return queryBuilder;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/search-query/types.ts b/apps/nestjs-backend/src/db-provider/search-query/types.ts
new file mode 100644
index 0000000000..a2d00ef92d
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/search-query/types.ts
@@ -0,0 +1,18 @@
+import type { CellValueType } from '@teable/core';
+import type { TableIndex } from '@teable/openapi';
+import type { Knex } from 'knex';
+import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQueryFilterContext } from '../../features/record/query-builder/record-query-builder.interface';
+import type { SearchQueryAbstract } from './abstract';
+
+export type ISearchCellValueType = Exclude;
+
+export type ISearchQueryConstructor = {
+ new (
+ originQueryBuilder: Knex.QueryBuilder,
+ field: IFieldInstance,
+ search: [string, string?, boolean?],
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext
+ ): SearchQueryAbstract;
+};
diff --git a/apps/nestjs-backend/src/db-provider/select-query/index.ts b/apps/nestjs-backend/src/db-provider/select-query/index.ts
new file mode 100644
index 0000000000..04e96a003c
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/select-query/index.ts
@@ -0,0 +1,8 @@
+// Abstract base class
+export { SelectQueryAbstract } from './select-query.abstract';
+
+// PostgreSQL implementation
+export { SelectQueryPostgres } from './postgres/select-query.postgres';
+
+// SQLite implementation
+export { SelectQuerySqlite } from './sqlite/select-query.sqlite';
diff --git a/apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts b/apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts
new file mode 100644
index 0000000000..aa430fcfa6
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts
@@ -0,0 +1,170 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { DbFieldType } from '@teable/core';
+import { describe, expect, it } from 'vitest';
+
+import { getDefaultDatetimeParsePattern } from '../../utils/default-datetime-parse-pattern';
+import { SelectQueryPostgres } from './select-query.postgres';
+
+describe('SelectQueryPostgres tzWrap', () => {
+ it('sanitizes text-like datetime inputs even when SQL contains timestamp tokens', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);
+ query.setCallMetadata([{ type: 'string', isFieldReference: false }] as unknown as never);
+
+ const expr =
+ "CONCAT(TO_CHAR(TIMEZONE('Etc/GMT-8', (col)::timestamptz), 'YYYY-MM-DD'), ' ', col2)";
+ const sql = query.datetimeFormat(expr, "'HH:mm:ss'");
+
+ expect(sql).toContain('BTRIM');
+ expect(sql).toContain('CASE WHEN');
+ expect(sql).toContain(getDefaultDatetimeParsePattern());
+ });
+
+ it('does not sanitize trusted datetime inputs', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);
+ query.setCallMetadata([{ type: 'datetime', isFieldReference: false }] as unknown as never);
+
+ const sql = query.datetimeFormat('col', "'HH:mm:ss'");
+ expect(sql).not.toContain('BTRIM');
+ });
+
+ it('reparses trusted datetime inputs through custom formats instead of returning the original value', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);
+ query.setCallMetadata([{ type: 'datetime', isFieldReference: false }] as unknown as never);
+
+ const sql = query.datetimeParse('col', "'MMYYYY'");
+
+ expect(sql).toContain('TO_CHAR');
+ expect(sql).toContain('TO_TIMESTAMP');
+ expect(sql).toContain(`AT TIME ZONE 'Asia/Shanghai'`);
+ expect(sql).not.toBe('(col)');
+ });
+});
+
+describe('SelectQueryPostgres truthinessScore', () => {
+ it('casts boolean-like expressions before COALESCE to avoid text/boolean type errors', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);
+ query.setCallMetadata([{ type: 'boolean', isFieldReference: false }] as unknown as never);
+
+ const sql = query.if("('true')::text", "'yes'", "'no'");
+ expect(sql).toContain("COALESCE((('true')::text)::boolean, FALSE)");
+ });
+
+ it('coerces json-like numeric branches in IF to avoid CASE jsonb/integer mismatches', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({
+ timeZone: 'Asia/Shanghai',
+ targetDbFieldType: DbFieldType.Real,
+ } as unknown as never);
+ query.setCallMetadata([
+ { type: 'string', isFieldReference: false },
+ {
+ type: 'string',
+ isFieldReference: true,
+ field: {
+ id: 'fldJsonNumeric',
+ isMultiple: true,
+ isLookup: true,
+ dbFieldName: '__json_numeric',
+ dbFieldType: DbFieldType.Json,
+ cellValueType: 'number',
+ },
+ },
+ { type: 'number', isFieldReference: false },
+ ] as unknown as never);
+
+ const sql = query.if('__cond', '"__json_numeric"', '0');
+ expect(sql).toContain('to_jsonb("__json_numeric")');
+ expect(sql).toContain('jsonb_array_elements_text');
+ expect(sql).toContain('double precision');
+ });
+});
+
+describe('SelectQueryPostgres countAll', () => {
+ it('counts JSON array length for multi-value field references', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({ tableAlias: 't' } as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'string',
+ isFieldReference: true,
+ field: {
+ id: 'fldUsers',
+ isMultiple: true,
+ isLookup: false,
+ dbFieldName: '__users',
+ dbFieldType: DbFieldType.Json,
+ cellValueType: 'string',
+ },
+ },
+ ] as unknown as never);
+
+ const sql = query.countAll('(SELECT json_agg(x) FROM x)');
+ expect(sql).toContain('jsonb_array_length');
+ expect(sql).toContain(`"t"."__users"`);
+ });
+
+ it('uses scalar null-check semantics for non-json fields', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({ tableAlias: 't' } as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'number',
+ isFieldReference: true,
+ field: {
+ id: 'fldNum',
+ isMultiple: false,
+ isLookup: false,
+ dbFieldName: '__num',
+ dbFieldType: DbFieldType.Real,
+ cellValueType: 'number',
+ },
+ },
+ ] as unknown as never);
+
+ expect(query.countAll('"t"."__num"')).toBe('CASE WHEN "t"."__num" IS NULL THEN 0 ELSE 1 END');
+ });
+});
+
+describe('SelectQueryPostgres FROMNOW/TONOW', () => {
+ it('applies unit conversion for FROMNOW', () => {
+ const query = new SelectQueryPostgres();
+
+ const daySql = query.fromNow('NOW()', "'day'");
+ const hourSql = query.fromNow('NOW()', "'hour'");
+ const secondSql = query.fromNow('NOW()', "'second'");
+
+ expect(daySql).toContain('/ 86400');
+ expect(hourSql).toContain('/ 3600');
+ expect(secondSql).not.toContain('/ 86400');
+ expect(secondSql).not.toContain('/ 3600');
+ });
+
+ it('keeps TONOW direction as now minus date for past-positive semantics', () => {
+ const query = new SelectQueryPostgres();
+
+ const sql = query.toNow('date_col', "'day'");
+ expect(sql).toContain('NOW() -');
+ expect(sql).not.toContain('date_col::timestamp - NOW()');
+ });
+});
+
+describe('SelectQueryPostgres workday', () => {
+ it('generates CTE-based workday SQL that skips weekends and holidays', () => {
+ const query = new SelectQueryPostgres();
+ query.setContext({ timeZone: 'Asia/Shanghai' } as unknown as never);
+ query.setCallMetadata([
+ { type: 'datetime', isFieldReference: true },
+ { type: 'number', isFieldReference: true },
+ ] as unknown as never);
+
+ const sql = query.workday('"t"."Date"', '"t"."Number"');
+ expect(sql).toContain('WITH params AS');
+ expect(sql).toContain('generate_series');
+ expect(sql).toContain('EXTRACT(DOW FROM c.candidate_date)');
+ expect(sql).toContain(`("t"."Number")::double precision`);
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.ts b/apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.ts
new file mode 100644
index 0000000000..ef084974b3
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.ts
@@ -0,0 +1,2059 @@
+/* eslint-disable regexp/no-unused-capturing-group */
+/* eslint-disable sonarjs/cognitive-complexity */
+import { DateFormattingPreset, DbFieldType, TimeFormatting } from '@teable/core';
+import type { IDatetimeFormatting } from '@teable/core';
+import type { ISelectFormulaConversionContext } from '../../../features/record/query-builder/sql-conversion.visitor';
+import {
+ buildDatetimeFormatSql,
+ buildDatetimeParseGuardRegex,
+ hasDatetimeTimezoneToken,
+ normalizeDatetimeFormatExpression,
+} from '../../utils/datetime-format.util';
+import { getDefaultDatetimeParsePattern } from '../../utils/default-datetime-parse-pattern';
+import {
+ isBooleanLikeParam,
+ isDatetimeLikeParam,
+ isJsonLikeParam,
+ isTextLikeParam,
+ isTrustedNumeric,
+ resolveFormulaParamInfo,
+} from '../../utils/formula-param-metadata.util';
+import { SelectQueryAbstract } from '../select-query.abstract';
+
+/**
+ * PostgreSQL-specific implementation of SELECT query functions
+ * Converts Teable formula functions to PostgreSQL SQL expressions suitable
+ * for use in SELECT statements. Unlike generated columns, these can use
+ * mutable functions and have different optimization strategies.
+ */
+export class SelectQueryPostgres extends SelectQueryAbstract {
+ private get tableAlias(): string | undefined {
+ const ctx = this.context as ISelectFormulaConversionContext | undefined;
+ return ctx?.tableAlias;
+ }
+
+ private qualifySystemColumn(column: string): string {
+ const quoted = `"${column}"`;
+ const alias = this.tableAlias;
+ return alias ? `"${alias}".${quoted}` : quoted;
+ }
+
+ private hasWrappingParentheses(expr: string): boolean {
+ if (!expr.startsWith('(') || !expr.endsWith(')')) {
+ return false;
+ }
+ let depth = 0;
+ for (let i = 0; i < expr.length; i++) {
+ const ch = expr[i];
+ if (ch === '(') {
+ depth++;
+ } else if (ch === ')') {
+ depth--;
+ if (depth === 0 && i < expr.length - 1) {
+ return false;
+ }
+ if (depth < 0) {
+ return false;
+ }
+ }
+ }
+ return depth === 0;
+ }
+
+ private stripOuterParentheses(expr: string): string {
+ let trimmed = expr.trim();
+ while (trimmed.length > 0 && this.hasWrappingParentheses(trimmed)) {
+ trimmed = trimmed.slice(1, -1).trim();
+ }
+ return trimmed;
+ }
+
+ private getParamInfo(index?: number) {
+ return resolveFormulaParamInfo(this.currentCallMetadata, index);
+ }
+
+ private isNumericLiteral(expr: string): boolean {
+ let trimmed = this.stripOuterParentheses(expr);
+
+ // Peel leading signs while trimming redundant outer parens
+ while (trimmed.startsWith('+') || trimmed.startsWith('-')) {
+ trimmed = trimmed.slice(1).trim();
+ trimmed = this.stripOuterParentheses(trimmed);
+ }
+
+ // Match plain numeric literal, with optional cast to a numeric type
+ const numericWithOptionalCast =
+ /^\(?\d+(\.\d+)?\)?(::(double precision|numeric|real|integer|bigint|smallint))?$/i;
+ if (numericWithOptionalCast.test(trimmed)) {
+ return true;
+ }
+
+ // Handle wrapped casts like ((7)::double precision)
+ const wrappedCastMatch = trimmed.match(/^\((.+)\)$/);
+ if (wrappedCastMatch) {
+ return this.isNumericLiteral(wrappedCastMatch[1]);
+ }
+
+ return false;
+ }
+
+ private toNumericSafe(
+ expr: string,
+ metadataIndex?: number,
+ opts?: { collate?: boolean; guardDateLike?: boolean }
+ ): string {
+ if (this.isNumericLiteral(expr)) {
+ return `(${expr})::double precision`;
+ }
+ const paramInfo = this.getParamInfo(metadataIndex);
+ const expressionFieldType = this.getExpressionFieldType(expr);
+ const targetDbType = (this.context as ISelectFormulaConversionContext | undefined)
+ ?.targetDbFieldType;
+
+ if (isBooleanLikeParam(paramInfo)) {
+ const boolScore = this.truthinessScore(expr, metadataIndex);
+ return `(${boolScore})::double precision`;
+ }
+ if (
+ paramInfo?.hasMetadata &&
+ isTextLikeParam(paramInfo) &&
+ !paramInfo.isJsonField &&
+ !paramInfo.isMultiValueField
+ ) {
+ return this.looseNumericCoercion(expr, opts);
+ }
+ if (expressionFieldType === DbFieldType.Text) {
+ return this.looseNumericCoercion(expr, opts);
+ }
+ if (paramInfo?.isJsonField || paramInfo?.isMultiValueField) {
+ return this.numericFromJson(expr);
+ }
+ if (expressionFieldType === DbFieldType.Json) {
+ return this.numericFromJson(expr);
+ }
+ if (isTrustedNumeric(paramInfo)) {
+ return `(${expr})::double precision`;
+ }
+ if (
+ !paramInfo?.hasMetadata &&
+ (expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer)
+ ) {
+ return `(${expr})::double precision`;
+ }
+ if (
+ !paramInfo?.hasMetadata &&
+ (targetDbType === DbFieldType.Real || targetDbType === DbFieldType.Integer)
+ ) {
+ return `(${expr})::double precision`;
+ }
+
+ return this.looseNumericCoercion(expr, opts);
+ }
+
+ private looseNumericCoercion(
+ expr: string,
+ opts?: { collate?: boolean; guardDateLike?: boolean }
+ ): string {
+ // Safely coerce any scalar to a floating-point number:
+ // - Strip everything except digits, sign, decimal point
+ // - Map empty string to NULL to avoid casting errors
+ // Cast to DOUBLE PRECISION so pg driver returns JS numbers (not strings as with NUMERIC)
+ if (this.isNumericLiteral(expr)) {
+ return `(${expr})::double precision`;
+ }
+ const shouldCollate = opts?.collate !== false;
+ const textExpr = shouldCollate ? `((${expr})::text) COLLATE "C"` : `((${expr})::text)`;
+ // Avoid treating obvious date-like strings (e.g., 2024/12/03) as numbers
+ const dateLikePattern = `'^[0-9]{1,4}[-/][0-9]{1,2}[-/][0-9]{1,4}( .*){0,1}$'`;
+ const collatedDatePattern = `${dateLikePattern} COLLATE "C"`;
+ const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;
+ const cleaned = `NULLIF(${sanitized}, '')`;
+ // Avoid "?" in the regex so knex.raw doesn't misinterpret it as a binding placeholder.
+ const numericPattern = `'^[+-]{0,1}(\\d+(\\.\\d+){0,1}|\\.\\d+)$'`;
+ const matchClause = shouldCollate
+ ? `${cleaned} COLLATE "C" ~ ${numericPattern} COLLATE "C"`
+ : `${cleaned} ~ ${numericPattern}`;
+ const guards = [`WHEN ${cleaned} IS NULL THEN NULL`];
+ if (opts?.guardDateLike) {
+ const datePattern = shouldCollate ? collatedDatePattern : dateLikePattern;
+ const dateGuardExpr = `${textExpr} ~ ${datePattern}`;
+ guards.push(`WHEN ${dateGuardExpr} THEN NULL`);
+ }
+ guards.push(`WHEN ${matchClause} THEN ${cleaned}::double precision`);
+ guards.push('ELSE NULL');
+ return `(CASE ${guards.join(' ')} END)`;
+ }
+
+ private numericFromJson(expr: string): string {
+ const jsonExpr = `to_jsonb(${expr})`;
+ const numericPattern = `'^[+-]{0,1}(\\d+(\\.\\d+){0,1}|\\.\\d+)$'`;
+ const collatedPattern = `${numericPattern} COLLATE "C"`;
+ const arraySum = `(SELECT SUM(CASE WHEN (elem.value COLLATE "C") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END) FROM jsonb_array_elements_text(${jsonExpr}) AS elem(value))`;
+ return `(CASE
+ WHEN ${expr} IS NULL THEN NULL
+ WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN ${arraySum}
+ ELSE ${this.looseNumericCoercion(expr)}
+ END)`;
+ }
+
+ private buildNumericArrayAggregation(expr: string): { sum: string; count: string } {
+ const arrayExpr = this.normalizeAnyToJsonArray(expr);
+ const numericPattern = `'^[+-]{0,1}(\\d+(\\.\\d+){0,1}|\\.\\d+)$'`;
+ const collatedPattern = `${numericPattern} COLLATE "C"`;
+ const numericValue = `(CASE WHEN (elem.value COLLATE "C") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END)`;
+ const numericCount = `(CASE WHEN (elem.value COLLATE "C") ~ ${collatedPattern} THEN 1 ELSE 0 END)`;
+
+ const sumExpr = `(SELECT SUM(${numericValue}) FROM jsonb_array_elements_text(${arrayExpr}) WITH ORDINALITY AS elem(value, ord))`;
+ const countExpr = `(SELECT SUM(${numericCount}) FROM jsonb_array_elements_text(${arrayExpr}) WITH ORDINALITY AS elem(value, ord))`;
+ return { sum: sumExpr, count: countExpr };
+ }
+
+ private buildNumericArrayExtremum(expr: string, op: 'max' | 'min'): string {
+ const arrayExpr = this.normalizeAnyToJsonArray(expr);
+ const numericPattern = `'^[+-]{0,1}(\\d+(\\.\\d+){0,1}|\\.\\d+)$'`;
+ const collatedPattern = `${numericPattern} COLLATE "C"`;
+ const numericValue = `(CASE WHEN (elem.value COLLATE "C") ~ ${collatedPattern} THEN elem.value::double precision ELSE NULL END)`;
+ const agg = op === 'max' ? 'MAX' : 'MIN';
+ return `(SELECT ${agg}(${numericValue}) FROM jsonb_array_elements_text(${arrayExpr}) WITH ORDINALITY AS elem(value, ord))`;
+ }
+
+ private collapseNumeric(expr: string, metadataIndex?: number): string {
+ const numericValue = this.toNumericSafe(expr, metadataIndex);
+ return `COALESCE(${numericValue}, 0)`;
+ }
+
+ private isDateLikeOperand(metadataIndex?: number): boolean {
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (!paramInfo?.hasMetadata) {
+ return false;
+ }
+ if (paramInfo.type === 'number') {
+ return false;
+ }
+ const hasFieldDateMetadata =
+ paramInfo.fieldDbType === DbFieldType.DateTime || paramInfo.fieldCellValueType === 'datetime';
+ const typeSaysDatetime =
+ isDatetimeLikeParam(paramInfo) && !paramInfo.fieldDbType && !paramInfo.fieldCellValueType;
+ const looksDatetime = hasFieldDateMetadata || typeSaysDatetime;
+
+ if (!looksDatetime) {
+ return false;
+ }
+
+ return !paramInfo.isJsonField && !paramInfo.isMultiValueField;
+ }
+
+ private buildDayInterval(expr: string, metadataIndex?: number): string {
+ const numeric = this.collapseNumeric(expr, metadataIndex);
+ return `(${numeric}) * INTERVAL '1 day'`;
+ }
+
+ private isEmptyStringLiteral(value: string): boolean {
+ return value.trim() === "''";
+ }
+
+ private isNullLiteral(value: string): boolean {
+ return this.stripOuterParentheses(value).toUpperCase() === 'NULL';
+ }
+
+ private shouldCoalesceNumericComparison(value: string, metadataIndex?: number): boolean {
+ if (this.isNumericLiteral(value)) {
+ return true;
+ }
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ return paramInfo ? isTrustedNumeric(paramInfo) || paramInfo.type === 'number' : false;
+ }
+
+ private normalizeNumericComparisonOperand(value: string, metadataIndex?: number): string {
+ if (!this.shouldCoalesceNumericComparison(value, metadataIndex)) {
+ return value;
+ }
+ const numericValue = this.toNumericSafe(value, metadataIndex);
+ return `COALESCE(${numericValue}, 0)`;
+ }
+
+ private normalizeBlankComparable(value: string, metadataIndex?: number): string {
+ const comparable = this.coerceToTextComparable(value, metadataIndex);
+ // Force text comparison so numeric fields compared against '' won't cast '' to double precision
+ const textComparable = this.ensureTextCollation(comparable);
+ return `COALESCE(NULLIF(${textComparable}, ''), '')`;
+ }
+
+ private ensureTextCollation(expr: string): string {
+ return `(${expr})::text`;
+ }
+
+ private isTextLikeExpression(value: string, metadataIndex?: number): boolean {
+ const trimmed = this.stripOuterParentheses(value);
+ if (this.isEmptyStringLiteral(trimmed)) {
+ return false;
+ }
+ if (/^'.*'$/.test(trimmed)) {
+ return true;
+ }
+
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (paramInfo?.hasMetadata) {
+ if (
+ paramInfo.fieldDbType === DbFieldType.Real ||
+ paramInfo.fieldDbType === DbFieldType.Integer ||
+ paramInfo.fieldCellValueType === 'number'
+ ) {
+ return false;
+ }
+ if (isTextLikeParam(paramInfo)) {
+ return true;
+ }
+ }
+
+ return this.getExpressionFieldType(value) === DbFieldType.Text;
+ }
+
+ private isNumericLikeExpression(value: string, metadataIndex?: number): boolean {
+ if (this.isNumericLiteral(value)) {
+ return true;
+ }
+
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (paramInfo?.hasMetadata) {
+ if (
+ paramInfo.type === 'number' ||
+ isTrustedNumeric(paramInfo) ||
+ isBooleanLikeParam(paramInfo)
+ ) {
+ return true;
+ }
+ if (
+ paramInfo.fieldDbType === DbFieldType.Real ||
+ paramInfo.fieldDbType === DbFieldType.Integer
+ ) {
+ return true;
+ }
+ if (paramInfo.fieldCellValueType === 'number') {
+ return true;
+ }
+ }
+
+ const expressionFieldType = this.getExpressionFieldType(value);
+ return expressionFieldType === DbFieldType.Real || expressionFieldType === DbFieldType.Integer;
+ }
+
+ private getExpressionFieldType(value: string): DbFieldType | undefined {
+ const trimmed = this.stripOuterParentheses(value);
+ const columnMatch = trimmed.match(/^"([^"]+)"$/) ?? trimmed.match(/^"[^"]+"\."([^"]+)"$/);
+ if (!columnMatch || columnMatch.length < 2) {
+ return undefined;
+ }
+
+ const columnName = columnMatch[1];
+ const table = this.context?.table;
+ const field =
+ table?.fieldList?.find((item) => item.dbFieldName === columnName) ??
+ table?.fields?.ordered?.find((item) => item.dbFieldName === columnName);
+ if (field) {
+ return field.dbFieldType as DbFieldType | undefined;
+ }
+
+ // Handle CTE-projected lookup/rollup aliases like "lookup_" that aren't part of the
+ // base table's dbFieldName list but still correspond to concrete field metadata.
+ const lookupMatch = columnName.match(/^(lookup|rollup)_(fld[A-Za-z0-9]+)$/);
+ if (lookupMatch && typeof table?.getField === 'function') {
+ const byId = table.getField(lookupMatch[2]);
+ return byId?.dbFieldType as DbFieldType | undefined;
+ }
+
+ return undefined;
+ }
+
+ private isHardTextExpression(value: string): boolean {
+ const trimmed = this.stripOuterParentheses(value);
+ if (this.isEmptyStringLiteral(trimmed)) {
+ return false;
+ }
+ if (/^'.+'$/.test(trimmed)) {
+ return true;
+ }
+ return this.getExpressionFieldType(value) === DbFieldType.Text;
+ }
+
+ private coerceArrayLikeToText(expr: string, metadataIndex?: number): string {
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ const shouldFlatten = paramInfo?.isJsonField || paramInfo?.isMultiValueField;
+
+ if (!shouldFlatten) {
+ return this.ensureTextCollation(expr);
+ }
+
+ const textExpr = `((${expr})::text)`;
+ const safeJsonExpr = `(CASE WHEN ${expr} IS NULL THEN NULL ELSE to_jsonb(${expr}) END)`;
+
+ const flattened = `(CASE
+ WHEN ${expr} IS NULL THEN NULL
+ WHEN ${safeJsonExpr} IS NULL THEN ${textExpr}
+ WHEN jsonb_typeof(${safeJsonExpr}) = 'array' THEN (
+ SELECT STRING_AGG(elem.value, ', ' ORDER BY elem.ordinality)
+ FROM jsonb_array_elements_text(${safeJsonExpr}) WITH ORDINALITY AS elem(value, ordinality)
+ )
+ WHEN jsonb_typeof(${safeJsonExpr}) = 'object' THEN COALESCE(
+ ${safeJsonExpr}->>'title',
+ ${safeJsonExpr}->>'name',
+ ${safeJsonExpr} #>> '{}'
+ )
+ ELSE ${safeJsonExpr} #>> '{}'
+ END)`;
+
+ return this.ensureTextCollation(flattened);
+ }
+
+ private buildJsonScalarCoercion(jsonExpr: string): string {
+ const elementScalar = `CASE
+ WHEN jsonb_typeof(elem.value) = 'object' THEN COALESCE(
+ elem.value->>'title',
+ elem.value->>'name',
+ elem.value #>> '{}'
+ )
+ WHEN jsonb_typeof(elem.value) = 'array' THEN NULL
+ ELSE elem.value #>> '{}'
+ END`;
+
+ return `CASE jsonb_typeof(${jsonExpr})
+ WHEN 'string' THEN (${jsonExpr}) #>> '{}'
+ WHEN 'number' THEN (${jsonExpr}) #>> '{}'
+ WHEN 'boolean' THEN (${jsonExpr}) #>> '{}'
+ WHEN 'null' THEN NULL
+ WHEN 'array' THEN COALESCE((
+ SELECT STRING_AGG(${elementScalar}, ', ' ORDER BY elem.ordinality)
+ FROM jsonb_array_elements(${jsonExpr}) WITH ORDINALITY AS elem(value, ordinality)
+ ), '')
+ WHEN 'object' THEN COALESCE(${jsonExpr}->>'title', ${jsonExpr}->>'name', ${jsonExpr} #>> '{}')
+ ELSE (${jsonExpr})::text
+ END`;
+ }
+
+ private coerceJsonExpressionToText(wrapped: string, metadataIndex?: number): string {
+ void metadataIndex;
+ const jsonExpr = `to_jsonb${wrapped}`;
+ return `(CASE
+ WHEN ${wrapped} IS NULL THEN NULL
+ ELSE ${this.buildJsonScalarCoercion(jsonExpr)}
+ END)`;
+ }
+
+ private coerceNonJsonExpressionToText(wrapped: string): string {
+ const jsonbValue = `to_jsonb${wrapped}`;
+
+ return `(CASE
+ WHEN ${wrapped} IS NULL THEN NULL
+ ELSE
+ ${this.buildJsonScalarCoercion(jsonbValue)}
+ END)`;
+ }
+
+ private coerceToTextComparable(value: string, metadataIndex?: number): string {
+ const trimmed = this.stripOuterParentheses(value);
+ if (!trimmed) {
+ return this.ensureTextCollation(value);
+ }
+ const isStringLiteral = /^'.*'$/.test(trimmed);
+ if (isStringLiteral) {
+ return trimmed;
+ }
+ if (trimmed.toUpperCase() === 'NULL') {
+ return 'NULL';
+ }
+
+ const wrapped = `(${value})`;
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ const expressionFieldType = this.getExpressionFieldType(value);
+ const numericField =
+ paramInfo?.fieldDbType === DbFieldType.Real ||
+ paramInfo?.fieldDbType === DbFieldType.Integer ||
+ paramInfo?.fieldCellValueType === 'number' ||
+ expressionFieldType === DbFieldType.Real ||
+ expressionFieldType === DbFieldType.Integer;
+ if (numericField && !paramInfo?.isJsonField && !paramInfo?.isMultiValueField) {
+ // Cast numeric operands to text so blank comparisons (e.g. field = '') don't try to
+ // coerce '' into double precision and raise 22P02.
+ return this.ensureTextCollation(wrapped);
+ }
+ if (paramInfo?.hasMetadata) {
+ if (isJsonLikeParam(paramInfo)) {
+ const coercedJson = this.coerceJsonExpressionToText(wrapped, metadataIndex);
+ return this.ensureTextCollation(coercedJson);
+ }
+
+ if (isTextLikeParam(paramInfo)) {
+ return this.isNumericLiteral(trimmed) ? this.ensureTextCollation(wrapped) : wrapped;
+ }
+
+ if (paramInfo.type && paramInfo.type !== 'unknown') {
+ return this.ensureTextCollation(`${wrapped}::text`);
+ }
+ }
+
+ // Heuristic: treat CASE/COALESCE/text-cast expressions as text without json wrapping to prevent
+ // runaway query growth in nested IF chains.
+ if (/^CASE\b/i.test(trimmed) || /::text\b/i.test(trimmed) || /\bCOALESCE\b/i.test(trimmed)) {
+ return this.ensureTextCollation(wrapped);
+ }
+
+ const jsonbValue = `to_jsonb${wrapped}`;
+ const flattenedArray = `(SELECT STRING_AGG(elem.value, ', ' ORDER BY elem.ordinality)
+ FROM jsonb_array_elements_text(${jsonbValue}) WITH ORDINALITY AS elem(value, ordinality))`;
+ const coerced = `(CASE
+ WHEN ${wrapped} IS NULL THEN NULL
+ ELSE
+ CASE jsonb_typeof(${jsonbValue})
+ WHEN 'string' THEN ${jsonbValue} #>> '{}'
+ WHEN 'number' THEN ${jsonbValue} #>> '{}'
+ WHEN 'boolean' THEN ${jsonbValue} #>> '{}'
+ WHEN 'null' THEN NULL
+ WHEN 'array' THEN COALESCE(${flattenedArray}, '')
+ ELSE ${jsonbValue}::text
+ END
+ END)`;
+ return this.ensureTextCollation(coerced);
+ }
+
+ private countANonNullExpression(value: string, metadataIndex?: number): string {
+ if (this.isTextLikeExpression(value, metadataIndex)) {
+ const normalizedComparable = this.normalizeBlankComparable(value, metadataIndex);
+ return `CASE WHEN ${value} IS NULL OR ${normalizedComparable} = '' THEN 0 ELSE 1 END`;
+ }
+
+ return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;
+ }
+
+ private normalizeIntervalUnit(
+ unitLiteral: string,
+ options?: { treatQuarterAsMonth?: boolean }
+ ): {
+ unit:
+ | 'millisecond'
+ | 'second'
+ | 'minute'
+ | 'hour'
+ | 'day'
+ | 'week'
+ | 'month'
+ | 'quarter'
+ | 'year';
+ factor: number;
+ } {
+ const normalized = unitLiteral.trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return { unit: 'millisecond', factor: 1 };
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return { unit: 'second', factor: 1 };
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return { unit: 'minute', factor: 1 };
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return { unit: 'hour', factor: 1 };
+ case 'week':
+ case 'weeks':
+ return { unit: 'week', factor: 1 };
+ case 'month':
+ case 'months':
+ return { unit: 'month', factor: 1 };
+ case 'quarter':
+ case 'quarters':
+ if (options?.treatQuarterAsMonth === false) {
+ return { unit: 'quarter', factor: 1 };
+ }
+ return { unit: 'month', factor: 3 };
+ case 'year':
+ case 'years':
+ return { unit: 'year', factor: 1 };
+ case 'day':
+ case 'days':
+ default:
+ return { unit: 'day', factor: 1 };
+ }
+ }
+
+ private normalizeDiffUnit(
+ unitLiteral: string
+ ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {
+ const normalized = unitLiteral.trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return 'millisecond';
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return 'second';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return 'minute';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return 'hour';
+ case 'week':
+ case 'weeks':
+ return 'week';
+ case 'month':
+ case 'months':
+ return 'month';
+ case 'quarter':
+ case 'quarters':
+ return 'quarter';
+ case 'year':
+ case 'years':
+ return 'year';
+ default:
+ return 'day';
+ }
+ }
+
+ private normalizeTruncateUnit(
+ unitLiteral: string
+ ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {
+ const normalized = unitLiteral.trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return 'millisecond';
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return 'second';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return 'minute';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return 'hour';
+ case 'week':
+ case 'weeks':
+ return 'week';
+ case 'month':
+ case 'months':
+ return 'month';
+ case 'quarter':
+ case 'quarters':
+ return 'quarter';
+ case 'year':
+ case 'years':
+ return 'year';
+ case 'day':
+ case 'days':
+ default:
+ return 'day';
+ }
+ }
+
+ private buildBlankAwareComparison(
+ operator: '=' | '<>',
+ left: string,
+ right: string,
+ metadataIndexes?: { left?: number; right?: number }
+ ): string {
+ const leftIndex = metadataIndexes?.left;
+ const rightIndex = metadataIndexes?.right;
+ const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);
+ const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);
+ const leftIsNullLiteral = this.isNullLiteral(left);
+ const rightIsNullLiteral = this.isNullLiteral(right);
+ const leftIsText = this.isTextLikeExpression(left, leftIndex);
+ const rightIsText = this.isTextLikeExpression(right, rightIndex);
+ const normalizeText =
+ leftIsEmptyLiteral ||
+ rightIsEmptyLiteral ||
+ leftIsNullLiteral ||
+ rightIsNullLiteral ||
+ leftIsText ||
+ rightIsText;
+
+ const leftIsNumericComparable = this.shouldCoalesceNumericComparison(left, leftIndex);
+ const rightIsNumericComparable = this.shouldCoalesceNumericComparison(right, rightIndex);
+
+ if (!normalizeText && (leftIsNumericComparable || rightIsNumericComparable)) {
+ const normalizedLeft = leftIsNumericComparable
+ ? this.normalizeNumericComparisonOperand(left, leftIndex)
+ : left;
+ const normalizedRight = rightIsNumericComparable
+ ? this.normalizeNumericComparisonOperand(right, rightIndex)
+ : right;
+ return `(${normalizedLeft} ${operator} ${normalizedRight})`;
+ }
+
+ if (!normalizeText) {
+ return `(${left} ${operator} ${right})`;
+ }
+
+ const normalizeOperand = (
+ value: string,
+ isEmptyLiteral: boolean,
+ isNullLiteral: boolean,
+ metadataIndex?: number
+ ) =>
+ isEmptyLiteral || isNullLiteral ? "''" : this.normalizeBlankComparable(value, metadataIndex);
+
+ const normalizedLeft = normalizeOperand(left, leftIsEmptyLiteral, leftIsNullLiteral, leftIndex);
+ const normalizedRight = normalizeOperand(
+ right,
+ rightIsEmptyLiteral,
+ rightIsNullLiteral,
+ rightIndex
+ );
+
+ return `(${normalizedLeft} ${operator} ${normalizedRight})`;
+ }
+
+ private sanitizeTimestampInput(date: string): string {
+ const trimmed = `NULLIF(BTRIM((${date})::text), '')`;
+ const pattern = getDefaultDatetimeParsePattern().replace(/'/g, "''");
+ return `CASE WHEN ${trimmed} IS NULL THEN NULL WHEN LOWER(${trimmed}) IN ('null', 'undefined') THEN NULL WHEN ${trimmed} ~ '${pattern}' THEN ${trimmed} ELSE NULL END`;
+ }
+
+ private isTrustedDatetime(expr: string, metadataIndex?: number): boolean {
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ if (paramInfo?.hasMetadata) {
+ const looksDatetime =
+ isDatetimeLikeParam(paramInfo) ||
+ paramInfo.fieldDbType === DbFieldType.DateTime ||
+ paramInfo.fieldCellValueType === 'datetime';
+ if (looksDatetime && !paramInfo.isJsonField && !paramInfo.isMultiValueField) {
+ return true;
+ }
+ return false;
+ }
+ return false;
+ }
+
+ private isTimestampish(expr: string): boolean {
+ const trimmed = this.stripOuterParentheses(expr);
+ return (
+ /::timestamp(tz)?\b/i.test(trimmed) ||
+ /\bAT\s+TIME\s+ZONE\b/i.test(trimmed) ||
+ /^NOW\(\)/i.test(trimmed) ||
+ /^CURRENT_TIMESTAMP/i.test(trimmed)
+ );
+ }
+
+ private shouldTreatAsDatetime(expr: string, metadataIndex?: number): boolean {
+ const paramInfo = this.getParamInfo(metadataIndex);
+ if (paramInfo?.hasMetadata) {
+ // Explicit numeric/boolean metadata should not be coerced into datetime even if the expression
+ // happens to contain timestamp-ish tokens (e.g. nested EXTRACT(... AT TIME ZONE ...)).
+ if (paramInfo.type === 'number' || paramInfo.type === 'boolean') {
+ return false;
+ }
+ const looksDatetime =
+ isDatetimeLikeParam(paramInfo) ||
+ paramInfo.fieldDbType === DbFieldType.DateTime ||
+ paramInfo.fieldCellValueType === 'datetime';
+ if (looksDatetime) {
+ return true;
+ }
+ }
+ return this.isTimestampish(expr);
+ }
+
+ private tzWrap(date: string, metadataIndex?: number): string {
+ const tz = this.context?.timeZone as string | undefined;
+ const shouldTreat = this.shouldTreatAsDatetime(date, metadataIndex);
+ const trusted = shouldTreat && this.isTrustedDatetime(date, metadataIndex);
+ const paramInfo = metadataIndex != null ? this.getParamInfo(metadataIndex) : undefined;
+ const isTextLike = Boolean(paramInfo?.hasMetadata && isTextLikeParam(paramInfo));
+ const alreadyTimestamp = !isTextLike && this.isTimestampish(date);
+ const needsSanitize = !(trusted || alreadyTimestamp);
+ const baseExpr = needsSanitize ? this.sanitizeTimestampInput(date) : `(${date})`;
+ const wrappedBase = needsSanitize ? `(${baseExpr})` : baseExpr;
+
+ if (!tz) {
+ return `${wrappedBase}::timestamp`;
+ }
+ // Sanitize single quotes to prevent SQL issues
+ const safeTz = tz.replace(/'/g, "''");
+ return `${wrappedBase}::timestamptz AT TIME ZONE '${safeTz}'`;
+ }
+
+ private buildTimezoneOffsetSql(localTimestampSql: string): string {
+ const tz = this.context?.timeZone as string | undefined;
+ if (!tz) {
+ return "'+00:00'";
+ }
+
+ const safeTz = tz.replace(/'/g, "''");
+ const offsetMinutesSql = `ROUND(EXTRACT(EPOCH FROM (((${localTimestampSql}) AT TIME ZONE 'UTC') - ((${localTimestampSql}) AT TIME ZONE '${safeTz}'))) / 60)::int`;
+
+ return `(CASE WHEN ${offsetMinutesSql} >= 0 THEN '+' ELSE '-' END || LPAD((ABS(${offsetMinutesSql}) / 60)::int::text, 2, '0') || ':' || LPAD((ABS(${offsetMinutesSql}) % 60)::int::text, 2, '0'))`;
+ }
+
+ private getDatePattern(date: DateFormattingPreset | string): string {
+ const presetValues = Object.values(DateFormattingPreset) as string[];
+ const normalizedPreset = presetValues.includes(date)
+ ? (date as DateFormattingPreset)
+ : DateFormattingPreset.ISO;
+
+ switch (normalizedPreset) {
+ case DateFormattingPreset.US:
+ return 'FMMM/FMDD/YYYY';
+ case DateFormattingPreset.European:
+ return 'FMDD/FMMM/YYYY';
+ case DateFormattingPreset.Asian:
+ return 'YYYY/MM/DD';
+ case DateFormattingPreset.YM:
+ return 'YYYY-MM';
+ case DateFormattingPreset.MD:
+ return 'MM-DD';
+ case DateFormattingPreset.Y:
+ return 'YYYY';
+ case DateFormattingPreset.M:
+ return 'MM';
+ case DateFormattingPreset.D:
+ return 'DD';
+ case DateFormattingPreset.ISO:
+ default:
+ return 'YYYY-MM-DD';
+ }
+ }
+
+ private getTimePattern(time?: TimeFormatting): string | null {
+ switch (time ?? TimeFormatting.None) {
+ case TimeFormatting.Hour24:
+ return 'HH24:MI';
+ case TimeFormatting.Hour12:
+ return 'HH12:MI AM';
+ default:
+ return null;
+ }
+ }
+
+ private buildDatetimeFormatting(formatting?: Partial): {
+ pattern: string;
+ timeZone: string;
+ } {
+ const datePattern = this.getDatePattern(formatting?.date ?? DateFormattingPreset.ISO);
+ const timePreset = formatting?.time as TimeFormatting | undefined;
+ const timePattern = this.getTimePattern(timePreset);
+ const pattern = (timePattern ? `${datePattern} ${timePattern}` : datePattern).replace(
+ /'/g,
+ "''"
+ );
+ const timeZone = (formatting?.timeZone ?? this.context?.timeZone ?? 'UTC').replace(/'/g, "''");
+ return { pattern, timeZone };
+ }
+
+ private normalizeAnyToJsonArray(expr: string): string {
+ const base = `(${expr})`;
+ const jsonExpr = `to_jsonb${base}`;
+ return `(CASE
+ WHEN ${base} IS NULL THEN '[]'::jsonb
+ WHEN jsonb_typeof(${jsonExpr}) = 'array' THEN COALESCE(${jsonExpr}, '[]'::jsonb)
+ ELSE jsonb_build_array(${jsonExpr})
+ END)`;
+ }
+
+ private extractFirstScalarFromMultiValue(expr: string): string {
+ const arrayExpr = this.normalizeAnyToJsonArray(expr);
+ return `(SELECT elem #>> '{}'
+ FROM jsonb_array_elements(${arrayExpr}) AS elem
+ WHERE jsonb_typeof(elem) NOT IN ('array','object')
+ LIMIT 1
+ )`;
+ }
+
+ private formatDatetimeOperandForSlice(expr: string, metadataIndex: number): string | null {
+ const paramInfo = this.getParamInfo(metadataIndex);
+ const cellValueType = paramInfo.fieldCellValueType?.toLowerCase();
+ let isDatetimeParam =
+ isDatetimeLikeParam(paramInfo) ||
+ cellValueType === 'datetime' ||
+ paramInfo.fieldDbType === DbFieldType.DateTime;
+
+ let formatting: IDatetimeFormatting | undefined;
+ let timeZoneSource: string | undefined;
+
+ if (paramInfo.hasMetadata) {
+ const fieldId = this.currentCallMetadata?.[metadataIndex]?.field?.id;
+ const field =
+ fieldId && this.context?.table ? this.context.table.getField(fieldId) : undefined;
+ formatting = (field as { options?: { formatting?: IDatetimeFormatting } } | undefined)
+ ?.options?.formatting;
+ timeZoneSource = formatting?.timeZone ?? this.context?.timeZone;
+ } else if (this.context?.table) {
+ const trimmed = this.stripOuterParentheses(expr);
+ const columnMatch = trimmed.match(/^"[^"]+"\."([^"]+)"$/) ?? trimmed.match(/^"([^"]+)"$/);
+ const dbName = columnMatch?.[1];
+ if (dbName) {
+ const field =
+ this.context.table.fieldList?.find((item) => item.dbFieldName === dbName) ??
+ this.context.table.fields?.ordered?.find((item) => item.dbFieldName === dbName);
+ if (field?.dbFieldType === DbFieldType.DateTime) {
+ isDatetimeParam = true;
+ formatting = (field as { options?: { formatting?: IDatetimeFormatting } } | undefined)
+ ?.options?.formatting;
+ timeZoneSource = formatting?.timeZone ?? this.context?.timeZone;
+ }
+ }
+ }
+
+ if (!isDatetimeParam) {
+ return null;
+ }
+
+ if (paramInfo.isMultiValueField) {
+ const normalizedArray = this.normalizeAnyToJsonArray(expr);
+ const { pattern, timeZone } = this.buildDatetimeFormatting({
+ ...(formatting ?? {}),
+ timeZone: timeZoneSource ?? this.context?.timeZone ?? 'UTC',
+ });
+ const scalar = `(CASE
+ WHEN jsonb_typeof(elem) = 'object' THEN COALESCE(elem->>'title', elem->>'name', elem #>> '{}')
+ ELSE elem #>> '{}'
+ END)`;
+ const sanitized = this.sanitizeTimestampInput(scalar);
+ const formatted = `TO_CHAR(((${sanitized}))::timestamptz AT TIME ZONE '${timeZone}', '${pattern}')`;
+ return `(SELECT string_agg(${formatted}, ', ' ORDER BY ord)
+ FROM jsonb_array_elements(${normalizedArray}) WITH ORDINALITY AS t(elem, ord)
+ )`;
+ }
+
+ let normalizedExpr = expr;
+ if (paramInfo.isMultiValueField) {
+ normalizedExpr = this.extractFirstScalarFromMultiValue(expr);
+ }
+
+ const { pattern, timeZone } = this.buildDatetimeFormatting({
+ ...(formatting ?? {}),
+ timeZone: timeZoneSource ?? this.context?.timeZone ?? 'UTC',
+ });
+ const sanitized = this.sanitizeTimestampInput(normalizedExpr);
+ return `TO_CHAR((${sanitized})::timestamptz AT TIME ZONE '${timeZone}', '${pattern}')`;
+ }
+
+ private buildSliceOperand(expr: string, metadataIndex: number): string {
+ const formattedDatetime = this.formatDatetimeOperandForSlice(expr, metadataIndex);
+ if (formattedDatetime) {
+ return `(${formattedDatetime})`;
+ }
+ return `(${expr})::text`;
+ }
+ // Numeric Functions
+ sum(params: string[]): string {
+ if (params.length === 0) {
+ return '0';
+ }
+
+ const terms = params.map((param, index) => {
+ const paramInfo = this.getParamInfo(index);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ const { sum } = this.buildNumericArrayAggregation(param);
+ return `COALESCE(${sum}, 0)`;
+ }
+ return this.collapseNumeric(param, index);
+ });
+ if (terms.length === 1) {
+ return terms[0];
+ }
+ return `(${terms.join(' + ')})`;
+ }
+
+ average(params: string[]): string {
+ if (params.length === 0) {
+ return '0';
+ }
+ const sumTerms: string[] = [];
+ const countTerms: string[] = [];
+
+ params.forEach((param, index) => {
+ const paramInfo = this.getParamInfo(index);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ const { sum, count } = this.buildNumericArrayAggregation(param);
+ sumTerms.push(`COALESCE(${sum}, 0)`);
+ countTerms.push(`COALESCE(${count}, 0)`);
+ } else {
+ const numericValue = this.toNumericSafe(param, index);
+ sumTerms.push(`COALESCE(${numericValue}, 0)`);
+ countTerms.push('1');
+ }
+ });
+
+ const numerator = sumTerms.length === 1 ? sumTerms[0] : `(${sumTerms.join(' + ')})`;
+ const hasDynamicCount = countTerms.some((c) => c !== '1');
+ if (!hasDynamicCount) {
+ return `(${numerator}) / ${params.length}`;
+ }
+ const denominator = countTerms.length === 1 ? countTerms[0] : `(${countTerms.join(' + ')})`;
+ return `(CASE WHEN ${denominator} = 0 THEN NULL ELSE (${numerator}) / ${denominator} END)`;
+ }
+
+ max(params: string[]): string {
+ const mapped = params.map((param, index) => {
+ const paramInfo = this.getParamInfo(index);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ return this.buildNumericArrayExtremum(param, 'max');
+ }
+ return this.toNumericSafe(param, index);
+ });
+ return `GREATEST(${this.joinParams(mapped)})`;
+ }
+
+ min(params: string[]): string {
+ const mapped = params.map((param, index) => {
+ const paramInfo = this.getParamInfo(index);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ return this.buildNumericArrayExtremum(param, 'min');
+ }
+ return this.toNumericSafe(param, index);
+ });
+ return `LEAST(${this.joinParams(mapped)})`;
+ }
+
+ round(value: string, precision?: string): string {
+ if (precision) {
+ return `ROUND(${value}::numeric, ${precision}::integer)`;
+ }
+ return `ROUND(${value}::numeric)`;
+ }
+
+ roundUp(value: string, precision?: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ if (precision !== undefined) {
+ const numericPrecision = this.toNumericSafe(precision, 1);
+ const factor = `POWER(10, ${numericPrecision}::integer)`;
+ return `CEIL(${numericValue} * ${factor}) / ${factor}`;
+ }
+ return `CEIL(${numericValue})`;
+ }
+
+ roundDown(value: string, precision?: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ if (precision !== undefined) {
+ const numericPrecision = this.toNumericSafe(precision, 1);
+ const factor = `POWER(10, ${numericPrecision}::integer)`;
+ return `FLOOR(${numericValue} * ${factor}) / ${factor}`;
+ }
+ return `FLOOR(${numericValue})`;
+ }
+
+ ceiling(value: string): string {
+ return `CEIL(${this.toNumericSafe(value, 0)})`;
+ }
+
+ floor(value: string): string {
+ return `FLOOR(${this.toNumericSafe(value, 0)})`;
+ }
+
+ even(value: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ const intValue = `FLOOR(${numericValue})::integer`;
+ return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 0 THEN ${intValue} ELSE ${intValue} + 1 END`;
+ }
+
+ odd(value: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ const intValue = `FLOOR(${numericValue})::integer`;
+ return `CASE WHEN ${numericValue} IS NULL THEN NULL WHEN ${intValue} % 2 = 1 THEN ${intValue} ELSE ${intValue} + 1 END`;
+ }
+
+ int(value: string): string {
+ return `FLOOR(${this.toNumericSafe(value, 0)})`;
+ }
+
+ abs(value: string): string {
+ return `ABS(${this.toNumericSafe(value, 0)})`;
+ }
+
+ sqrt(value: string): string {
+ return `SQRT(${this.toNumericSafe(value, 0)})`;
+ }
+
+ power(base: string, exponent: string): string {
+ const baseValue = this.toNumericSafe(base, 0);
+ const exponentValue = this.toNumericSafe(exponent, 1);
+ return `POWER(${baseValue}, ${exponentValue})`;
+ }
+
+ exp(value: string): string {
+ return `EXP(${this.toNumericSafe(value, 0)})`;
+ }
+
+ log(value: string, base?: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ if (base !== undefined) {
+ const numericBase = this.toNumericSafe(base, 1);
+ const baseLog = `LN(${numericBase})`;
+ return `(LN(${numericValue}) / NULLIF(${baseLog}, 0))`;
+ }
+ return `LN(${numericValue})`;
+ }
+
+ mod(dividend: string, divisor: string): string {
+ const safeDividend = this.toNumericSafe(dividend, 0);
+ const safeDivisor = this.toNumericSafe(divisor, 1);
+ return `(CASE WHEN (${safeDivisor}) IS NULL OR (${safeDivisor}) = 0 THEN NULL ELSE MOD((${safeDividend})::numeric, (${safeDivisor})::numeric)::double precision END)`;
+ }
+
+ value(text: string): string {
+ return this.toNumericSafe(text, 0, { collate: true });
+ }
+
+ // Text Functions
+ concatenate(params: string[]): string {
+ return `CONCAT(${this.joinParams(params.map((p, idx) => this.coerceArrayLikeToText(p, idx)))})`;
+ }
+
+ stringConcat(left: string, right: string): string {
+ return `CONCAT(${this.coerceArrayLikeToText(left, 0)}, ${this.coerceArrayLikeToText(
+ right,
+ 1
+ )})`;
+ }
+
+ find(searchText: string, withinText: string, startNum?: string): string {
+ const normalizedSearch = this.ensureTextCollation(searchText);
+ const normalizedWithin = this.ensureTextCollation(withinText);
+
+ if (startNum) {
+ return `POSITION(${normalizedSearch} IN SUBSTRING(${normalizedWithin} FROM ${startNum}::integer)) + ${startNum}::integer - 1`;
+ }
+ return `POSITION(${normalizedSearch} IN ${normalizedWithin})`;
+ }
+
+ search(searchText: string, withinText: string, startNum?: string): string {
+ const normalizedSearch = this.ensureTextCollation(searchText);
+ const normalizedWithin = this.ensureTextCollation(withinText);
+
+ // Similar to find but case-insensitive
+ if (startNum) {
+ return `POSITION(UPPER(${normalizedSearch}) IN UPPER(SUBSTRING(${normalizedWithin} FROM ${startNum}::integer))) + ${startNum}::integer - 1`;
+ }
+ return `POSITION(UPPER(${normalizedSearch}) IN UPPER(${normalizedWithin}))`;
+ }
+
+ mid(text: string, startNum: string, numChars: string): string {
+ const operand = this.buildSliceOperand(text, 0);
+ return `SUBSTRING(${operand} FROM ${startNum}::integer FOR ${numChars}::integer)`;
+ }
+
+ left(text: string, numChars: string): string {
+ const operand = this.buildSliceOperand(text, 0);
+ return `LEFT(${operand}, ${numChars}::integer)`;
+ }
+
+ right(text: string, numChars: string): string {
+ const operand = this.buildSliceOperand(text, 0);
+ return `RIGHT(${operand}, ${numChars}::integer)`;
+ }
+
+ replace(oldText: string, startNum: string, numChars: string, newText: string): string {
+ const source = this.buildSliceOperand(oldText, 0);
+ const replacement = this.buildSliceOperand(newText, 3);
+ return `OVERLAY(${source} PLACING ${replacement} FROM ${startNum}::integer FOR ${numChars}::integer)`;
+ }
+
+ regexpReplace(text: string, pattern: string, replacement: string): string {
+ const source = this.ensureTextCollation(text);
+ const regex = this.ensureTextCollation(pattern);
+ const replacementText = this.ensureTextCollation(replacement);
+ return `REGEXP_REPLACE(${source}, ${regex}, ${replacementText}, 'g')`;
+ }
+
+ substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {
+ const source = this.coerceArrayLikeToText(text, 0);
+ const search = this.coerceArrayLikeToText(oldText, 1);
+ const replacement = this.coerceArrayLikeToText(newText, 2);
+ if (instanceNum) {
+ // PostgreSQL doesn't have direct support for replacing specific instance
+ // This is a simplified implementation
+ return `REPLACE(${source}, ${search}, ${replacement})`;
+ }
+ return `REPLACE(${source}, ${search}, ${replacement})`;
+ }
+
+ lower(text: string): string {
+ const operand = this.coerceArrayLikeToText(text, 0);
+ return `LOWER(${operand})`;
+ }
+
+ upper(text: string): string {
+ const operand = this.coerceArrayLikeToText(text, 0);
+ return `UPPER(${operand})`;
+ }
+
+ rept(text: string, numTimes: string): string {
+ const operand = this.coerceArrayLikeToText(text, 0);
+ return `REPEAT(${operand}, ${numTimes}::integer)`;
+ }
+
+ trim(text: string): string {
+ const operand = this.coerceArrayLikeToText(text, 0);
+ return `TRIM(${operand})`;
+ }
+
+ len(text: string): string {
+ // Cast to text to avoid calling LENGTH() on numeric types (e.g., auto-number)
+ const operand = this.ensureTextCollation(this.coerceToTextComparable(text, 0));
+ return `LENGTH(${operand})`;
+ }
+
+ t(value: string): string {
+ return `CASE WHEN ${value} IS NULL THEN '' ELSE ${value}::text END`;
+ }
+
+ encodeUrlComponent(text: string): string {
+ const textExpr = `(${text})::text`;
+ const encodedSql = `(SELECT string_agg(
+ CASE
+ WHEN byte_val BETWEEN 48 AND 57
+ OR byte_val BETWEEN 65 AND 90
+ OR byte_val BETWEEN 97 AND 122
+ OR byte_val IN (45, 95, 46, 33, 126, 42, 39, 40, 41)
+ THEN chr(byte_val)
+ ELSE '%' || UPPER(LPAD(to_hex(byte_val), 2, '0'))
+ END,
+ ''
+ ORDER BY ord
+ )
+ FROM (
+ SELECT ord, get_byte(src.bytes, ord) AS byte_val
+ FROM (SELECT convert_to(${textExpr}, 'UTF8') AS bytes) AS src
+ CROSS JOIN generate_series(0, octet_length(src.bytes) - 1) AS ord
+ ) AS utf8_bytes)`;
+
+ return `(CASE WHEN ${text} IS NULL THEN NULL ELSE COALESCE(${encodedSql}, '') END)`;
+ }
+
+ // DateTime Functions - These can use mutable functions in SELECT context
+ now(): string {
+ return `NOW()`;
+ }
+
+ today(): string {
+ return `CURRENT_DATE`;
+ }
+
+ dateAdd(date: string, count: string, unit: string): string {
+ const { unit: cleanUnit, factor } = this.normalizeIntervalUnit(unit.replace(/^'|'$/g, ''));
+ const countExpr = `(${count})`;
+ const scaledCount = factor === 1 ? `${countExpr}` : `${countExpr} * ${factor}`;
+ const tsExpr = this.tzWrap(date, 0);
+ if (cleanUnit === 'quarter') {
+ return `${tsExpr} + (${scaledCount}) * INTERVAL '1 month'`;
+ }
+ return `${tsExpr} + (${scaledCount}) * INTERVAL '1 ${cleanUnit}'`;
+ }
+
+ datestr(date: string): string {
+ return `(${this.tzWrap(date, 0)})::date::text`;
+ }
+
+ private buildMonthDiff(startDate: string, endDate: string): string {
+ const startExpr = this.tzWrap(startDate, 0);
+ const endExpr = this.tzWrap(endDate, 1);
+ const startYear = `EXTRACT(YEAR FROM ${startExpr})`;
+ const endYear = `EXTRACT(YEAR FROM ${endExpr})`;
+ const startMonth = `EXTRACT(MONTH FROM ${startExpr})`;
+ const endMonth = `EXTRACT(MONTH FROM ${endExpr})`;
+ const startDay = `EXTRACT(DAY FROM ${startExpr})`;
+ const endDay = `EXTRACT(DAY FROM ${endExpr})`;
+ const startLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${startExpr}) + INTERVAL '1 month - 1 day'))`;
+ const endLastDay = `EXTRACT(DAY FROM (DATE_TRUNC('month', ${endExpr}) + INTERVAL '1 month - 1 day'))`;
+
+ const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;
+ const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;
+ const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;
+
+ return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;
+ }
+
+ datetimeDiff(startDate: string, endDate: string, unit: string): string {
+ const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));
+ const diffSeconds = `EXTRACT(EPOCH FROM (${this.tzWrap(startDate, 0)} - ${this.tzWrap(
+ endDate,
+ 1
+ )}))`;
+ switch (diffUnit) {
+ case 'millisecond':
+ return `(${diffSeconds}) * 1000`;
+ case 'second':
+ return `(${diffSeconds})`;
+ case 'minute':
+ return `(${diffSeconds}) / 60`;
+ case 'hour':
+ return `(${diffSeconds}) / 3600`;
+ case 'week':
+ return `(${diffSeconds}) / (86400 * 7)`;
+ case 'month':
+ return this.buildMonthDiff(startDate, endDate);
+ case 'quarter':
+ return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;
+ case 'year': {
+ const monthDiff = this.buildMonthDiff(startDate, endDate);
+ return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;
+ }
+ case 'day':
+ default:
+ return `(${diffSeconds}) / 86400`;
+ }
+ }
+
+ datetimeFormat(date: string, format: string): string {
+ const timestampExpr = this.tzWrap(date, 0);
+ return buildDatetimeFormatSql(
+ timestampExpr,
+ format,
+ this.buildTimezoneOffsetSql(timestampExpr)
+ );
+ }
+
+ datetimeParse(dateString: string, format?: string): string {
+ const valueExpr = `(${dateString})`;
+ const trustedDatetimeInput = this.hasTrustedDatetimeInput(0);
+
+ if (format == null) {
+ return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);
+ }
+ const trimmedFormat = format.trim();
+ if (!trimmedFormat || trimmedFormat === 'undefined' || trimmedFormat.toLowerCase() === 'null') {
+ return trustedDatetimeInput ? valueExpr : this.parseDatetimeParseWithoutFormat(valueExpr);
+ }
+ if (trustedDatetimeInput) {
+ const localTimestampExpr = this.tzWrap(valueExpr, 0);
+ const formattedExpr = buildDatetimeFormatSql(
+ localTimestampExpr,
+ trimmedFormat,
+ this.buildTimezoneOffsetSql(localTimestampExpr)
+ );
+ return this.parseDatetimeParseWithFormat(formattedExpr, trimmedFormat);
+ }
+
+ return this.parseDatetimeParseWithFormat(`${valueExpr}::text`, trimmedFormat, valueExpr);
+ }
+
+ day(date: string): string {
+ return `EXTRACT(DAY FROM ${this.tzWrap(date, 0)})::int`;
+ }
+
+ private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {
+ const diffUnit = this.normalizeDiffUnit(unit.replace(/^'|'$/g, ''));
+ const diffSeconds = `EXTRACT(EPOCH FROM (${nowExpr} - ${dateExpr}))`;
+ const diffMonths = `EXTRACT(MONTH FROM AGE(${nowExpr}, ${dateExpr})) + EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr})) * 12`;
+ const diffYears = `EXTRACT(YEAR FROM AGE(${nowExpr}, ${dateExpr}))`;
+ switch (diffUnit) {
+ case 'millisecond':
+ return `(${diffSeconds}) * 1000`;
+ case 'second':
+ return `(${diffSeconds})`;
+ case 'minute':
+ return `(${diffSeconds}) / 60`;
+ case 'hour':
+ return `(${diffSeconds}) / 3600`;
+ case 'week':
+ return `(${diffSeconds}) / (86400 * 7)`;
+ case 'month':
+ return diffMonths;
+ case 'quarter':
+ return `(${diffMonths}) / 3.0`;
+ case 'year':
+ return diffYears;
+ case 'day':
+ default:
+ return `(${diffSeconds}) / 86400`;
+ }
+ }
+
+ fromNow(date: string, unit = 'day'): string {
+ const tz = this.context?.timeZone?.replace(/'/g, "''");
+ if (tz) {
+ return this.buildNowDiffByUnit(`(NOW() AT TIME ZONE '${tz}')`, this.tzWrap(date, 0), unit);
+ }
+ return this.buildNowDiffByUnit('NOW()', `${date}::timestamp`, unit);
+ }
+
+ hour(date: string): string {
+ return `EXTRACT(HOUR FROM ${this.tzWrap(date, 0)})::int`;
+ }
+
+ isAfter(date1: string, date2: string): string {
+ return `${this.tzWrap(date1, 0)} > ${this.tzWrap(date2, 1)}`;
+ }
+
+ isBefore(date1: string, date2: string): string {
+ return `${this.tzWrap(date1, 0)} < ${this.tzWrap(date2, 1)}`;
+ }
+
+ isSame(date1: string, date2: string, unit?: string): string {
+ if (unit) {
+ const trimmed = unit.trim();
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
+ const literal = trimmed.slice(1, -1);
+ const normalizedUnit = this.normalizeTruncateUnit(literal);
+ const safeUnit = normalizedUnit.replace(/'/g, "''");
+ return `DATE_TRUNC('${safeUnit}', ${this.tzWrap(date1, 0)}) = DATE_TRUNC('${safeUnit}', ${this.tzWrap(date2, 1)})`;
+ }
+ return `DATE_TRUNC(${unit}, ${this.tzWrap(date1, 0)}) = DATE_TRUNC(${unit}, ${this.tzWrap(
+ date2,
+ 1
+ )})`;
+ }
+ return `${this.tzWrap(date1, 0)} = ${this.tzWrap(date2, 1)}`;
+ }
+
+ lastModifiedTime(): string {
+ // This would typically reference a system column
+ return this.qualifySystemColumn('__last_modified_time');
+ }
+
+ minute(date: string): string {
+ return `EXTRACT(MINUTE FROM ${this.tzWrap(date, 0)})::int`;
+ }
+
+ month(date: string): string {
+ return `EXTRACT(MONTH FROM ${this.tzWrap(date, 0)})::int`;
+ }
+
+ second(date: string): string {
+ return `EXTRACT(SECOND FROM ${this.tzWrap(date, 0)})::int`;
+ }
+
+ timestr(date: string): string {
+ return `(${this.tzWrap(date, 0)})::time::text`;
+ }
+
+ toNow(date: string, unit = 'day'): string {
+ return this.fromNow(date, unit);
+ }
+
+ weekNum(date: string): string {
+ return `EXTRACT(WEEK FROM ${this.tzWrap(date, 0)})::int`;
+ }
+
+ weekday(date: string, startDayOfWeek?: string): string {
+ const weekdaySql = `EXTRACT(DOW FROM ${this.tzWrap(date, 0)})::int`;
+ if (!startDayOfWeek) {
+ return weekdaySql;
+ }
+
+ const normalizedStartDay = `LOWER(BTRIM(COALESCE((${startDayOfWeek})::text, '')))`;
+ return `CASE WHEN ${normalizedStartDay} = 'monday' THEN ((${weekdaySql} + 6) % 7) ELSE ${weekdaySql} END`;
+ }
+
+ workday(startDate: string, days: string, holidayStr?: string): string {
+ if (!this.isDateLikeOperand(0)) {
+ return 'NULL';
+ }
+ const startDateSql = `(${this.tzWrap(startDate, 0)})::date`;
+ const dayCountSql = `COALESCE((${this.toNumericSafe(days, 1)})::integer, 0)`;
+ const holidayTextSql = holidayStr ? `COALESCE((${holidayStr})::text, '')` : `''`;
+
+ return `(
+ WITH params AS (
+ SELECT ${startDateSql} AS start_date, ${dayCountSql} AS day_count, ${holidayTextSql} AS holiday_text
+ ),
+ holiday_parts AS (
+ SELECT BTRIM(part) AS holiday_part
+ FROM params p
+ CROSS JOIN LATERAL regexp_split_to_table(p.holiday_text, ',') AS part
+ ),
+ holiday_dates AS (
+ SELECT DISTINCT TO_DATE(LEFT(holiday_part, 10), 'YYYY-MM-DD') AS holiday_date
+ FROM holiday_parts
+ WHERE holiday_part <> ''
+ AND holiday_part ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
+ AND TO_CHAR(TO_DATE(LEFT(holiday_part, 10), 'YYYY-MM-DD'), 'YYYY-MM-DD') = LEFT(holiday_part, 10)
+ ),
+ candidates AS (
+ SELECT
+ (p.start_date + CASE WHEN p.day_count >= 0 THEN seq.n ELSE -seq.n END)::date AS candidate_date,
+ seq.n
+ FROM params p
+ CROSS JOIN LATERAL generate_series(1, ABS(p.day_count) * 7 + 366) AS seq(n)
+ ),
+ workdays AS (
+ SELECT c.candidate_date, c.n
+ FROM candidates c
+ LEFT JOIN holiday_dates h ON h.holiday_date = c.candidate_date
+ WHERE EXTRACT(DOW FROM c.candidate_date)::int NOT IN (0, 6)
+ AND h.holiday_date IS NULL
+ ORDER BY c.n
+ )
+ SELECT CASE
+ WHEN p.day_count = 0 THEN p.start_date::timestamp
+ ELSE (
+ SELECT w.candidate_date::timestamp
+ FROM workdays w
+ OFFSET ABS(p.day_count) - 1
+ LIMIT 1
+ )
+ END
+ FROM params p
+ )`;
+ }
+
+ workdayDiff(startDate: string, endDate: string): string {
+ if (!this.isDateLikeOperand(0) || !this.isDateLikeOperand(1)) {
+ return 'NULL';
+ }
+ // Simplified implementation with timezone-aware, sanitized inputs
+ const start = `(${this.tzWrap(startDate, 0)})`;
+ const end = `(${this.tzWrap(endDate, 1)})`;
+ return `${end}::date - ${start}::date`;
+ }
+
+ year(date: string): string {
+ return `EXTRACT(YEAR FROM ${this.tzWrap(date, 0)})::int`;
+ }
+
+ createdTime(): string {
+ // This would typically reference a system column
+ return this.qualifySystemColumn('__created_time');
+ }
+
+ // Logical Functions
+ private truthinessScore(value: string, metadataIndex?: number): string {
+ const normalizedValue = this.stripOuterParentheses(value);
+ const wrapped = `(${normalizedValue})`;
+ const paramInfo = this.getParamInfo(metadataIndex);
+
+ if (isBooleanLikeParam(paramInfo)) {
+ // Prefer the simplest form when the operand is a real boolean column to keep generated SQL
+ // readable and stable for tests; otherwise cast to boolean to avoid COALESCE type errors
+ // when the operand is boolean-ish text (e.g. 'true'/'false') in raw projection contexts.
+ const boolExpr =
+ paramInfo.isFieldReference && paramInfo.fieldDbType === DbFieldType.Boolean
+ ? wrapped
+ : `${wrapped}::boolean`;
+ return `CASE WHEN COALESCE(${boolExpr}, FALSE) THEN 1 ELSE 0 END`;
+ }
+
+ if (
+ paramInfo?.isJsonField ||
+ paramInfo?.isMultiValueField ||
+ paramInfo?.fieldDbType === DbFieldType.Json
+ ) {
+ return `CASE
+ WHEN ${wrapped} IS NULL THEN 0
+ WHEN (${wrapped})::text IN ('null', '[]', '{}', '') THEN 0
+ ELSE 1
+ END`;
+ }
+
+ if (isTrustedNumeric(paramInfo)) {
+ const numericExpr = this.toNumericSafe(normalizedValue, metadataIndex);
+ return `CASE WHEN COALESCE(${numericExpr}, 0) <> 0 THEN 1 ELSE 0 END`;
+ }
+
+ const conditionType = `pg_typeof${wrapped}::text`;
+ const numericTypes = "('smallint','integer','bigint','numeric','double precision','real')";
+ const wrappedText = `(${wrapped})::text`;
+ const booleanTruthyScore = `CASE WHEN LOWER(${wrappedText}) IN ('t','true','1') THEN 1 ELSE 0 END`;
+ const numericTruthyScore = `CASE WHEN ${wrappedText} ~ '^\\s*[+-]{0,1}0*(\\.0*){0,1}\\s*$' THEN 0 ELSE 1 END`;
+ const fallbackTruthyScore = `CASE
+ WHEN COALESCE(${wrappedText}, '') = '' THEN 0
+ WHEN LOWER(${wrappedText}) = 'null' THEN 0
+ ELSE 1
+ END`;
+ return `CASE
+ WHEN ${wrapped} IS NULL THEN 0
+ WHEN ${conditionType} = 'boolean' THEN ${booleanTruthyScore}
+ WHEN ${conditionType} IN ${numericTypes} THEN ${numericTruthyScore}
+ ELSE ${fallbackTruthyScore}
+ END`;
+ }
+
+ if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
+ const truthinessScore = this.truthinessScore(condition, 0);
+ const trueIsBlank = this.isEmptyStringLiteral(valueIfTrue) || this.isNullLiteral(valueIfTrue);
+ const falseIsBlank =
+ this.isEmptyStringLiteral(valueIfFalse) || this.isNullLiteral(valueIfFalse);
+ const targetType = (this.context as ISelectFormulaConversionContext | undefined)
+ ?.targetDbFieldType;
+ const resultIsDatetime =
+ targetType === DbFieldType.DateTime || this.isDateLikeOperand(1) || this.isDateLikeOperand(2);
+ if (resultIsDatetime) {
+ const trueBranch = trueIsBlank ? 'NULL' : this.tzWrap(valueIfTrue, 1);
+ const falseBranch = falseIsBlank ? 'NULL' : this.tzWrap(valueIfFalse, 2);
+ return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranch} ELSE ${falseBranch} END`;
+ }
+ const trueIsText = this.isTextLikeExpression(valueIfTrue, 1);
+ const falseIsText = this.isTextLikeExpression(valueIfFalse, 2);
+ const trueIsHardText = this.isHardTextExpression(valueIfTrue);
+ const falseIsHardText = this.isHardTextExpression(valueIfFalse);
+ const hasTextBranch = (trueIsText && !trueIsBlank) || (falseIsText && !falseIsBlank);
+ const numericWithBlank =
+ (trueIsBlank && !falseIsHardText && !falseIsText) ||
+ (falseIsBlank && !trueIsHardText && !trueIsText);
+ if (numericWithBlank) {
+ const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);
+ const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
+ return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
+ }
+ const targetIsNumeric = targetType === DbFieldType.Real || targetType === DbFieldType.Integer;
+ const hasNumericBranch =
+ this.isNumericLikeExpression(valueIfTrue, 1) || this.isNumericLikeExpression(valueIfFalse, 2);
+ if (targetIsNumeric || (hasNumericBranch && !hasTextBranch)) {
+ const trueBranchNumeric = trueIsBlank ? 'NULL' : this.toNumericSafe(valueIfTrue, 1);
+ const falseBranchNumeric = falseIsBlank ? 'NULL' : this.toNumericSafe(valueIfFalse, 2);
+ return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranchNumeric} ELSE ${falseBranchNumeric} END`;
+ }
+ const blankPresent = trueIsBlank || falseIsBlank;
+ const hasTextAfterBlank = blankPresent ? false : hasTextBranch;
+ const normalizeBlankAsNull = !hasTextAfterBlank && blankPresent;
+ const trueBranch = hasTextAfterBlank
+ ? this.coerceToTextComparable(valueIfTrue, 1)
+ : trueIsBlank && normalizeBlankAsNull
+ ? 'NULL'
+ : valueIfTrue;
+ const falseBranch = hasTextAfterBlank
+ ? this.coerceToTextComparable(valueIfFalse, 2)
+ : falseIsBlank && normalizeBlankAsNull
+ ? 'NULL'
+ : valueIfFalse;
+ return `CASE WHEN (${truthinessScore}) = 1 THEN ${trueBranch} ELSE ${falseBranch} END`;
+ }
+
+ and(params: string[]): string {
+ return `(${params.map((p) => `(${p})`).join(' AND ')})`;
+ }
+
+ or(params: string[]): string {
+ return `(${params.map((p) => `(${p})`).join(' OR ')})`;
+ }
+
+ not(value: string): string {
+ return `NOT (${value})`;
+ }
+
+ xor(params: string[]): string {
+ // PostgreSQL doesn't have XOR, implement using AND/OR logic
+ if (params.length === 2) {
+ return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;
+ }
+ // For multiple params, use modulo approach
+ return `(${params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`).join(' + ')}) % 2 = 1`;
+ }
+
+ blank(): string {
+ return 'NULL';
+ }
+
+ error(_message: string): string {
+ // In SELECT context, we can use functions that raise errors
+ return `(SELECT pg_catalog.pg_advisory_unlock_all() WHERE FALSE)`;
+ }
+
+ isError(_value: string): string {
+ // Check if value would cause an error - simplified implementation
+ return `FALSE`;
+ }
+
+ switch(
+ expression: string,
+ cases: Array<{ case: string; result: string }>,
+ defaultResult?: string
+ ): string {
+ const hasTextResult =
+ cases.some((c) => this.isTextLikeExpression(c.result)) ||
+ (defaultResult ? this.isTextLikeExpression(defaultResult) : false);
+
+ const normalizeResult = (value: string) =>
+ hasTextResult ? this.coerceToTextComparable(value) : value;
+
+ const normalizeCaseValue = (value: string) =>
+ hasTextResult ? this.coerceToTextComparable(value) : value;
+
+ const baseExpr = hasTextResult ? this.coerceToTextComparable(expression, 0) : expression;
+ let sql = `CASE ${baseExpr}`;
+ for (const caseItem of cases) {
+ sql += ` WHEN ${normalizeCaseValue(caseItem.case)} THEN ${normalizeResult(caseItem.result)}`;
+ }
+ if (defaultResult) {
+ sql += ` ELSE ${normalizeResult(defaultResult)}`;
+ }
+ sql += ` END`;
+ return sql;
+ }
+
+ // Array Functions - More flexible in SELECT context
+ count(params: string[]): string {
+ const countChecks = params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 ELSE 0 END`);
+ return `(${countChecks.join(' + ')})`;
+ }
+
+ countA(params: string[]): string {
+ const blankAwareChecks = params.map((p, index) => this.countANonNullExpression(p, index));
+ return `(${blankAwareChecks.join(' + ')})`;
+ }
+
+ countAll(value: string): string {
+ const paramInfo = this.getParamInfo(0);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ const baseExpr =
+ paramInfo.isFieldReference && paramInfo.fieldDbName
+ ? this.tableAlias
+ ? `"${this.tableAlias}"."${paramInfo.fieldDbName}"`
+ : `"${paramInfo.fieldDbName}"`
+ : value;
+ const normalized = `COALESCE(NULLIF((${baseExpr})::jsonb, 'null'::jsonb), '[]'::jsonb)`;
+ return `(CASE
+ WHEN jsonb_typeof(${normalized}) = 'array' THEN jsonb_array_length(${normalized})
+ ELSE 1
+ END)`;
+ }
+
+ return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;
+ }
+
+ private normalizeJsonbArray(array: string): string {
+ return `(
+ CASE
+ WHEN ${array} IS NULL THEN '[]'::jsonb
+ WHEN jsonb_typeof(to_jsonb(${array})) = 'array' THEN to_jsonb(${array})
+ ELSE jsonb_build_array(to_jsonb(${array}))
+ END
+ )`;
+ }
+
+ private buildJsonbArrayUnion(
+ arrays: string[],
+ opts?: { filterNulls?: boolean; withOrdinal?: boolean }
+ ): string {
+ const selects = arrays.map((array, index) => {
+ const normalizedArray = this.normalizeJsonbArray(array);
+ const whereClause = opts?.filterNulls
+ ? " WHERE elem.value IS NOT NULL AND elem.value != 'null' AND elem.value != ''"
+ : '';
+ const ordinality = opts?.withOrdinal ? ', ord' : '';
+ return `SELECT elem.value, ${index} AS arg_index${ordinality}
+ FROM jsonb_array_elements_text(${normalizedArray}) WITH ORDINALITY AS elem(value, ord)${whereClause}`;
+ });
+
+ if (selects.length === 0) {
+ return 'SELECT NULL::text AS value, 0 AS arg_index, 0 AS ord WHERE FALSE';
+ }
+
+ return selects.join(' UNION ALL ');
+ }
+
+ arrayJoin(array: string, separator?: string): string {
+ const sep = separator || `','`;
+ const normalizedArray = this.normalizeJsonbArray(array);
+ return `(
+ SELECT string_agg(
+ elem.value,
+ ${sep}
+ )
+ FROM jsonb_array_elements_text(${normalizedArray}) AS elem(value)
+ )`;
+ }
+
+ arrayUnique(arrays: string[]): string {
+ const unionQuery = this.buildJsonbArrayUnion(arrays, { withOrdinal: true });
+ return `ARRAY(
+ SELECT DISTINCT ON (value) value
+ FROM (${unionQuery}) AS combined(value, arg_index, ord)
+ ORDER BY value, arg_index, ord
+ )`;
+ }
+
+ arrayFlatten(arrays: string[]): string {
+ const unionQuery = this.buildJsonbArrayUnion(arrays, { withOrdinal: true });
+ return `ARRAY(
+ SELECT value
+ FROM (${unionQuery}) AS combined(value, arg_index, ord)
+ ORDER BY arg_index, ord
+ )`;
+ }
+
+ arrayCompact(arrays: string[]): string {
+ const unionQuery = this.buildJsonbArrayUnion(arrays, { filterNulls: true, withOrdinal: true });
+ return `ARRAY(
+ SELECT value
+ FROM (${unionQuery}) AS combined(value, arg_index, ord)
+ ORDER BY arg_index, ord
+ )`;
+ }
+
+ // System Functions
+ recordId(): string {
+ // This would typically reference the primary key
+ return this.qualifySystemColumn('__id');
+ }
+
+ autoNumber(): string {
+ // This would typically reference an auto-increment column
+ return this.qualifySystemColumn('__auto_number');
+ }
+
+ textAll(value: string): string {
+ return `${value}::text`;
+ }
+
+ // Binary Operations
+ add(left: string, right: string): string {
+ const leftIsDate = this.isDateLikeOperand(0);
+ const rightIsDate = this.isDateLikeOperand(1);
+
+ if (leftIsDate && !rightIsDate) {
+ return `(${this.tzWrap(left, 0)} + ${this.buildDayInterval(right, 1)})`;
+ }
+
+ if (!leftIsDate && rightIsDate) {
+ return `(${this.tzWrap(right, 1)} + ${this.buildDayInterval(left, 0)})`;
+ }
+
+ const l = this.collapseNumeric(left, 0);
+ const r = this.collapseNumeric(right, 1);
+ return `((${l}) + (${r}))`;
+ }
+
+ subtract(left: string, right: string): string {
+ const leftIsDate = this.isDateLikeOperand(0);
+ const rightIsDate = this.isDateLikeOperand(1);
+
+ if (leftIsDate && !rightIsDate) {
+ return `(${this.tzWrap(left, 0)} - ${this.buildDayInterval(right, 1)})`;
+ }
+
+ if (leftIsDate && rightIsDate) {
+ return `(EXTRACT(EPOCH FROM (${this.tzWrap(left, 0)} - ${this.tzWrap(right, 1)})) / 86400)`;
+ }
+
+ const l = this.collapseNumeric(left, 0);
+ const r = this.collapseNumeric(right, 1);
+ return `((${l}) - (${r}))`;
+ }
+
+ multiply(left: string, right: string): string {
+ const l = this.collapseNumeric(left, 0);
+ const r = this.collapseNumeric(right, 1);
+ return `((${l}) * (${r}))`;
+ }
+
+ divide(left: string, right: string): string {
+ const numerator = this.collapseNumeric(left, 0);
+ const denominator = this.toNumericSafe(right, 1);
+ return `(CASE WHEN (${denominator}) IS NULL OR (${denominator}) = 0 THEN NULL ELSE (${numerator} / ${denominator}) END)`;
+ }
+
+ modulo(left: string, right: string): string {
+ const dividend = this.collapseNumeric(left, 0);
+ const divisor = this.toNumericSafe(right, 1);
+ return `(CASE WHEN (${divisor}) IS NULL OR (${divisor}) = 0 THEN NULL ELSE MOD((${dividend})::numeric, (${divisor})::numeric)::double precision END)`;
+ }
+
+ // Comparison Operations
+ equal(left: string, right: string): string {
+ return this.buildBlankAwareComparison('=', left, right, { left: 0, right: 1 });
+ }
+
+ notEqual(left: string, right: string): string {
+ return this.buildBlankAwareComparison('<>', left, right, { left: 0, right: 1 });
+ }
+
+ greaterThan(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} > ${normalizedRight})`;
+ }
+
+ lessThan(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} < ${normalizedRight})`;
+ }
+
+ greaterThanOrEqual(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} >= ${normalizedRight})`;
+ }
+
+ lessThanOrEqual(left: string, right: string): string {
+ const normalizedLeft = this.normalizeNumericComparisonOperand(left, 0);
+ const normalizedRight = this.normalizeNumericComparisonOperand(right, 1);
+ return `(${normalizedLeft} <= ${normalizedRight})`;
+ }
+
+ // Logical Operations
+ logicalAnd(left: string, right: string): string {
+ return `(${left} AND ${right})`;
+ }
+
+ logicalOr(left: string, right: string): string {
+ return `(${left} OR ${right})`;
+ }
+
+ bitwiseAnd(left: string, right: string): string {
+ // Handle cases where operands might not be valid integers
+ // Use COALESCE and NULLIF to safely convert to integer, defaulting to 0 for invalid values
+ return `(
+ COALESCE(
+ CASE
+ WHEN ${left}::text ~ '^-?[0-9]+$' THEN
+ NULLIF(${left}::text, '')::integer
+ ELSE NULL
+ END,
+ 0
+ ) &
+ COALESCE(
+ CASE
+ WHEN ${right}::text ~ '^-?[0-9]+$' THEN
+ NULLIF(${right}::text, '')::integer
+ ELSE NULL
+ END,
+ 0
+ )
+ )`;
+ }
+
+ // Unary Operations
+ unaryMinus(value: string): string {
+ const numericValue = this.toNumericSafe(value, 0);
+ return `(-(${numericValue}))`;
+ }
+
+ // Field Reference
+ fieldReference(_fieldId: string, columnName: string): string {
+ return `"${columnName}"`;
+ }
+
+ // Literals
+ stringLiteral(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`;
+ }
+
+ numberLiteral(value: number): string {
+ return value.toString();
+ }
+
+ booleanLiteral(value: boolean): string {
+ return value ? 'TRUE' : 'FALSE';
+ }
+
+ nullLiteral(): string {
+ return 'NULL';
+ }
+
+ // Utility methods for type conversion and validation
+ castToNumber(value: string): string {
+ return `${value}::numeric`;
+ }
+
+ castToString(value: string): string {
+ return `${value}::text`;
+ }
+
+ castToBoolean(value: string): string {
+ return `${value}::boolean`;
+ }
+
+ castToDate(value: string): string {
+ return `${value}::timestamp`;
+ }
+
+ // Handle null values and type checking
+ isNull(value: string): string {
+ return `${value} IS NULL`;
+ }
+
+ coalesce(params: string[]): string {
+ return `COALESCE(${this.joinParams(params)})`;
+ }
+
+ // Parentheses for grouping
+ parentheses(expression: string): string {
+ return `(${expression})`;
+ }
+
+ private guardDefaultDatetimeParse(valueExpr: string): string {
+ const textExpr = `${valueExpr}::text`;
+ const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;
+ const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;
+ const pattern = getDefaultDatetimeParsePattern();
+ return `(CASE WHEN ${valueExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} IS NULL THEN NULL WHEN ${sanitizedExpr} ~ '${pattern}' THEN ${valueExpr} ELSE NULL END)`;
+ }
+
+ private parseDatetimeParseWithoutFormat(valueExpr: string): string {
+ const textExpr = `${valueExpr}::text`;
+ const trimmedExpr = `NULLIF(BTRIM(${textExpr}), '')`;
+ const sanitizedExpr = `CASE WHEN ${trimmedExpr} IS NULL THEN NULL WHEN LOWER(${trimmedExpr}) IN ('null', 'undefined') THEN NULL ELSE ${trimmedExpr} END`;
+ const pattern = getDefaultDatetimeParsePattern();
+ const hasClockTime = `(${sanitizedExpr} ~ '[ T][0-9]{1,2}:[0-9]{2}')`;
+ const hasExplicitTimeZone = `(${sanitizedExpr} ~* '(Z|[+-][0-9]{2}:[0-9]{2}|[+-][0-9]{4}|[+-][0-9]{2})$')`;
+ const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, "''");
+ const localTimestampExpr = `(${sanitizedExpr})::timestamp AT TIME ZONE '${safeTz}'`;
+ const explicitZoneExpr = `(${sanitizedExpr})::timestamptz`;
+
+ return `(CASE
+ WHEN ${valueExpr} IS NULL THEN NULL
+ WHEN ${sanitizedExpr} IS NULL THEN NULL
+ WHEN ${sanitizedExpr} ~ '${pattern}' THEN
+ (CASE
+ WHEN ${hasClockTime} AND NOT ${hasExplicitTimeZone} THEN ${localTimestampExpr}
+ ELSE ${explicitZoneExpr}
+ END)
+ ELSE NULL
+ END)`;
+ }
+
+ private parseDatetimeParseWithFormat(
+ textExpr: string,
+ formatExpr: string,
+ nullGuardExpr: string = textExpr
+ ): string {
+ const normalizedFormat = normalizeDatetimeFormatExpression(formatExpr);
+ const toTimestampExpr = `TO_TIMESTAMP(${textExpr}::text, ${normalizedFormat})`;
+ const safeTz = (this.context?.timeZone ?? 'UTC').replace(/'/g, "''");
+ const hasTimezoneToken = hasDatetimeTimezoneToken(formatExpr);
+ const parsedExpr =
+ hasTimezoneToken === false
+ ? `(${toTimestampExpr})::timestamp AT TIME ZONE '${safeTz}'`
+ : toTimestampExpr;
+ const guardPattern = buildDatetimeParseGuardRegex(formatExpr);
+ if (!guardPattern) {
+ return parsedExpr;
+ }
+ const escapedPattern = guardPattern.replace(/'/g, "''");
+ return `(CASE WHEN ${nullGuardExpr} IS NULL THEN NULL WHEN ${textExpr} = '' THEN NULL WHEN ${textExpr} ~ '${escapedPattern}' THEN ${parsedExpr} ELSE NULL END)`;
+ }
+
+ private hasTrustedDatetimeInput(index: number): boolean {
+ const paramInfo = this.getParamInfo(index);
+ if (!paramInfo.hasMetadata) {
+ return false;
+ }
+ if (!isDatetimeLikeParam(paramInfo)) {
+ return false;
+ }
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/select-query/select-query.abstract.ts b/apps/nestjs-backend/src/db-provider/select-query/select-query.abstract.ts
new file mode 100644
index 0000000000..0e1d375f89
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/select-query/select-query.abstract.ts
@@ -0,0 +1,192 @@
+import type { IFormulaParamMetadata } from '@teable/core';
+import type {
+ ISelectQueryInterface,
+ IFormulaConversionContext,
+} from '../../features/record/query-builder/sql-conversion.visitor';
+
+/**
+ * Abstract base class for SELECT query implementations
+ * Provides common functionality and default implementations for converting
+ * Teable formula expressions to database-specific SQL suitable for SELECT statements
+ *
+ * Unlike generated columns, SELECT queries can:
+ * - Use mutable functions (NOW(), RANDOM(), etc.)
+ * - Have different performance characteristics
+ * - Support more complex expressions that might not be allowed in generated columns
+ * - Use subqueries and window functions more freely
+ */
+export abstract class SelectQueryAbstract implements ISelectQueryInterface {
+ /** Current conversion context */
+ protected context?: IFormulaConversionContext;
+ protected currentCallMetadata?: IFormulaParamMetadata[];
+
+ /** Set the conversion context */
+ setContext(context: IFormulaConversionContext): void {
+ this.context = context;
+ }
+
+ setCallMetadata(metadata?: IFormulaParamMetadata[]): void {
+ this.currentCallMetadata = metadata;
+ }
+
+ /** Check if we're in a SELECT query context (always true for this class) */
+ protected get isSelectQueryContext(): boolean {
+ return true;
+ }
+
+ /** Helper method to join parameters with commas */
+ protected joinParams(params: string[]): string {
+ return params.join(', ');
+ }
+
+ /** Helper method to wrap expression in parentheses if needed */
+ protected wrapInParentheses(expression: string): string {
+ return `(${expression})`;
+ }
+
+ /** Helper method to handle null values in expressions */
+ protected handleNullValue(expression: string, defaultValue: string = 'NULL'): string {
+ return `COALESCE(${expression}, ${defaultValue})`;
+ }
+
+ // Numeric Functions
+ abstract sum(params: string[]): string;
+ abstract average(params: string[]): string;
+ abstract max(params: string[]): string;
+ abstract min(params: string[]): string;
+ abstract round(value: string, precision?: string): string;
+ abstract roundUp(value: string, precision?: string): string;
+ abstract roundDown(value: string, precision?: string): string;
+ abstract ceiling(value: string): string;
+ abstract floor(value: string): string;
+ abstract even(value: string): string;
+ abstract odd(value: string): string;
+ abstract int(value: string): string;
+ abstract abs(value: string): string;
+ abstract sqrt(value: string): string;
+ abstract power(base: string, exponent: string): string;
+ abstract exp(value: string): string;
+ abstract log(value: string, base?: string): string;
+ abstract mod(dividend: string, divisor: string): string;
+ abstract value(text: string): string;
+
+ // Text Functions
+ abstract concatenate(params: string[]): string;
+ abstract stringConcat(left: string, right: string): string;
+ abstract find(searchText: string, withinText: string, startNum?: string): string;
+ abstract search(searchText: string, withinText: string, startNum?: string): string;
+ abstract mid(text: string, startNum: string, numChars: string): string;
+ abstract left(text: string, numChars: string): string;
+ abstract right(text: string, numChars: string): string;
+ abstract replace(oldText: string, startNum: string, numChars: string, newText: string): string;
+ abstract regexpReplace(text: string, pattern: string, replacement: string): string;
+ abstract substitute(text: string, oldText: string, newText: string, instanceNum?: string): string;
+ abstract lower(text: string): string;
+ abstract upper(text: string): string;
+ abstract rept(text: string, numTimes: string): string;
+ abstract trim(text: string): string;
+ abstract len(text: string): string;
+ abstract t(value: string): string;
+ abstract encodeUrlComponent(text: string): string;
+
+ // DateTime Functions
+ abstract now(): string;
+ abstract today(): string;
+ abstract dateAdd(date: string, count: string, unit: string): string;
+ abstract datestr(date: string): string;
+ abstract datetimeDiff(startDate: string, endDate: string, unit: string): string;
+ abstract datetimeFormat(date: string, format: string): string;
+ abstract datetimeParse(dateString: string, format?: string): string;
+ abstract day(date: string): string;
+ abstract fromNow(date: string, unit?: string): string;
+ abstract hour(date: string): string;
+ abstract isAfter(date1: string, date2: string): string;
+ abstract isBefore(date1: string, date2: string): string;
+ abstract isSame(date1: string, date2: string, unit?: string): string;
+ abstract lastModifiedTime(): string;
+ abstract minute(date: string): string;
+ abstract month(date: string): string;
+ abstract second(date: string): string;
+ abstract timestr(date: string): string;
+ abstract toNow(date: string, unit?: string): string;
+ abstract weekNum(date: string): string;
+ abstract weekday(date: string, startDayOfWeek?: string): string;
+ abstract workday(startDate: string, days: string, holidayStr?: string): string;
+ abstract workdayDiff(startDate: string, endDate: string): string;
+ abstract year(date: string): string;
+ abstract createdTime(): string;
+
+ // Logical Functions
+ abstract if(condition: string, valueIfTrue: string, valueIfFalse: string): string;
+ abstract and(params: string[]): string;
+ abstract or(params: string[]): string;
+ abstract not(value: string): string;
+ abstract xor(params: string[]): string;
+ abstract blank(): string;
+ abstract error(message: string): string;
+ abstract isError(value: string): string;
+ abstract switch(
+ expression: string,
+ cases: Array<{ case: string; result: string }>,
+ defaultResult?: string
+ ): string;
+
+ // Array Functions
+ abstract count(params: string[]): string;
+ abstract countA(params: string[]): string;
+ abstract countAll(value: string): string;
+ abstract arrayJoin(array: string, separator?: string): string;
+ abstract arrayUnique(arrays: string[]): string;
+ abstract arrayFlatten(arrays: string[]): string;
+ abstract arrayCompact(arrays: string[]): string;
+
+ // System Functions
+ abstract recordId(): string;
+ abstract autoNumber(): string;
+ abstract textAll(value: string): string;
+
+ // Binary Operations
+ abstract add(left: string, right: string): string;
+ abstract subtract(left: string, right: string): string;
+ abstract multiply(left: string, right: string): string;
+ abstract divide(left: string, right: string): string;
+ abstract modulo(left: string, right: string): string;
+
+ // Comparison Operations
+ abstract equal(left: string, right: string): string;
+ abstract notEqual(left: string, right: string): string;
+ abstract greaterThan(left: string, right: string): string;
+ abstract lessThan(left: string, right: string): string;
+ abstract greaterThanOrEqual(left: string, right: string): string;
+ abstract lessThanOrEqual(left: string, right: string): string;
+
+ // Logical Operations
+ abstract logicalAnd(left: string, right: string): string;
+ abstract logicalOr(left: string, right: string): string;
+ abstract bitwiseAnd(left: string, right: string): string;
+
+ // Unary Operations
+ abstract unaryMinus(value: string): string;
+
+ // Field Reference
+ abstract fieldReference(fieldId: string, columnName: string): string;
+
+ // Literals
+ abstract stringLiteral(value: string): string;
+ abstract numberLiteral(value: number): string;
+ abstract booleanLiteral(value: boolean): string;
+ abstract nullLiteral(): string;
+
+ // Utility methods for type conversion and validation
+ abstract castToNumber(value: string): string;
+ abstract castToString(value: string): string;
+ abstract castToBoolean(value: string): string;
+ abstract castToDate(value: string): string;
+
+ // Handle null values and type checking
+ abstract isNull(value: string): string;
+ abstract coalesce(params: string[]): string;
+
+ // Parentheses for grouping
+ abstract parentheses(expression: string): string;
+}
diff --git a/apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.spec.ts b/apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.spec.ts
new file mode 100644
index 0000000000..51b3dc9bf5
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.spec.ts
@@ -0,0 +1,250 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { DbFieldType } from '@teable/core';
+import { describe, expect, it } from 'vitest';
+
+import { SelectQuerySqlite } from './select-query.sqlite';
+
+describe('SelectQuerySqlite unit-aware date helpers', () => {
+ const query = new SelectQuerySqlite();
+
+ const dateAddCases: Array<{ literal: string; unit: string; factor: number }> = [
+ { literal: 'millisecond', unit: 'seconds', factor: 0.001 },
+ { literal: 'milliseconds', unit: 'seconds', factor: 0.001 },
+ { literal: 'ms', unit: 'seconds', factor: 0.001 },
+ { literal: 'second', unit: 'seconds', factor: 1 },
+ { literal: 'seconds', unit: 'seconds', factor: 1 },
+ { literal: 'sec', unit: 'seconds', factor: 1 },
+ { literal: 'secs', unit: 'seconds', factor: 1 },
+ { literal: 'minute', unit: 'minutes', factor: 1 },
+ { literal: 'minutes', unit: 'minutes', factor: 1 },
+ { literal: 'min', unit: 'minutes', factor: 1 },
+ { literal: 'mins', unit: 'minutes', factor: 1 },
+ { literal: 'hour', unit: 'hours', factor: 1 },
+ { literal: 'hours', unit: 'hours', factor: 1 },
+ { literal: 'h', unit: 'hours', factor: 1 },
+ { literal: 'hr', unit: 'hours', factor: 1 },
+ { literal: 'hrs', unit: 'hours', factor: 1 },
+ { literal: 'day', unit: 'days', factor: 1 },
+ { literal: 'days', unit: 'days', factor: 1 },
+ { literal: 'week', unit: 'days', factor: 7 },
+ { literal: 'weeks', unit: 'days', factor: 7 },
+ { literal: 'month', unit: 'months', factor: 1 },
+ { literal: 'months', unit: 'months', factor: 1 },
+ { literal: 'quarter', unit: 'months', factor: 3 },
+ { literal: 'quarters', unit: 'months', factor: 3 },
+ { literal: 'year', unit: 'years', factor: 1 },
+ { literal: 'years', unit: 'years', factor: 1 },
+ ];
+
+ it.each(dateAddCases)(
+ 'dateAdd normalizes unit "%s" to SQLite modifier "%s"',
+ ({ literal, unit, factor }) => {
+ const sql = query.dateAdd('date_col', 'count_expr', `'${literal}'`);
+ const scaled = factor === 1 ? '(count_expr)' : `(count_expr) * ${factor}`;
+ expect(sql).toBe(`DATETIME(date_col, (${scaled}) || ' ${unit}')`);
+ }
+ );
+
+ const datetimeDiffCases: Array<{ literal: string; expected: string }> = [
+ {
+ literal: 'millisecond',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60 * 1000',
+ },
+ {
+ literal: 'milliseconds',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60 * 1000',
+ },
+ {
+ literal: 'ms',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60 * 1000',
+ },
+ {
+ literal: 's',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',
+ },
+ {
+ literal: 'second',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',
+ },
+ {
+ literal: 'seconds',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',
+ },
+ {
+ literal: 'sec',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',
+ },
+ {
+ literal: 'secs',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60 * 60',
+ },
+ {
+ literal: 'minute',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',
+ },
+ {
+ literal: 'minutes',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',
+ },
+ {
+ literal: 'min',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',
+ },
+ {
+ literal: 'mins',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0 * 60',
+ },
+ {
+ literal: 'hour',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',
+ },
+ {
+ literal: 'hours',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',
+ },
+ {
+ literal: 'h',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',
+ },
+ {
+ literal: 'hr',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',
+ },
+ {
+ literal: 'hrs',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) * 24.0',
+ },
+ {
+ literal: 'week',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) / 7.0',
+ },
+ {
+ literal: 'weeks',
+ expected: '((JULIANDAY(date_start) - JULIANDAY(date_end))) / 7.0',
+ },
+ { literal: 'day', expected: '(JULIANDAY(date_start) - JULIANDAY(date_end))' },
+ { literal: 'days', expected: '(JULIANDAY(date_start) - JULIANDAY(date_end))' },
+ ];
+
+ it.each(datetimeDiffCases)('datetimeDiff normalizes unit "%s"', ({ literal, expected }) => {
+ const sql = query.datetimeDiff('date_start', 'date_end', `'${literal}'`);
+ expect(sql).toBe(expected);
+ });
+
+ const isSameCases: Array<{ literal: string; format: string }> = [
+ { literal: 'millisecond', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 'milliseconds', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 'ms', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 's', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 'second', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 'seconds', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 'sec', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 'secs', format: '%Y-%m-%d %H:%M:%S' },
+ { literal: 'minute', format: '%Y-%m-%d %H:%M' },
+ { literal: 'minutes', format: '%Y-%m-%d %H:%M' },
+ { literal: 'min', format: '%Y-%m-%d %H:%M' },
+ { literal: 'mins', format: '%Y-%m-%d %H:%M' },
+ { literal: 'hour', format: '%Y-%m-%d %H' },
+ { literal: 'hours', format: '%Y-%m-%d %H' },
+ { literal: 'h', format: '%Y-%m-%d %H' },
+ { literal: 'hr', format: '%Y-%m-%d %H' },
+ { literal: 'hrs', format: '%Y-%m-%d %H' },
+ { literal: 'day', format: '%Y-%m-%d' },
+ { literal: 'days', format: '%Y-%m-%d' },
+ { literal: 'week', format: '%Y-%W' },
+ { literal: 'weeks', format: '%Y-%W' },
+ { literal: 'month', format: '%Y-%m' },
+ { literal: 'months', format: '%Y-%m' },
+ { literal: 'year', format: '%Y' },
+ { literal: 'years', format: '%Y' },
+ ];
+
+ it.each(isSameCases)('isSame normalizes unit "%s"', ({ literal, format }) => {
+ const sql = query.isSame('date_a', 'date_b', `'${literal}'`);
+ expect(sql).toBe(`STRFTIME('${format}', date_a) = STRFTIME('${format}', date_b)`);
+ });
+
+ describe('numeric aggregate rewrites', () => {
+ it('sum rewrites multiple params to addition with numeric coercion', () => {
+ const sql = query.sum(['column_a', 'column_b', '10']);
+ expect(sql).toBe(
+ '(COALESCE(CAST((column_a) AS REAL), 0) + COALESCE(CAST((column_b) AS REAL), 0) + COALESCE(CAST((10) AS REAL), 0))'
+ );
+ });
+
+ it('average divides the rewritten sum by parameter count', () => {
+ const sql = query.average(['column_a', '10']);
+ expect(sql).toBe(
+ '((COALESCE(CAST((column_a) AS REAL), 0) + COALESCE(CAST((10) AS REAL), 0))) / 2'
+ );
+ });
+ });
+});
+
+describe('SelectQuerySqlite countAll', () => {
+ it('counts JSON array length for multi-value field references', () => {
+ const query = new SelectQuerySqlite();
+ query.setContext({ tableAlias: 't' } as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'string',
+ isFieldReference: true,
+ field: {
+ id: 'fldUsers',
+ isMultiple: true,
+ isLookup: false,
+ dbFieldName: '__users',
+ dbFieldType: DbFieldType.Json,
+ cellValueType: 'string',
+ },
+ },
+ ] as unknown as never);
+
+ const sql = query.countAll('(SELECT json_group_array(x) FROM x)');
+ expect(sql).toContain('json_array_length');
+ expect(sql).toContain('"t"."__users"');
+ });
+
+ it('uses scalar null-check semantics for non-json fields', () => {
+ const query = new SelectQuerySqlite();
+ query.setContext({ tableAlias: 't' } as unknown as never);
+ query.setCallMetadata([
+ {
+ type: 'number',
+ isFieldReference: true,
+ field: {
+ id: 'fldNum',
+ isMultiple: false,
+ isLookup: false,
+ dbFieldName: '__num',
+ dbFieldType: DbFieldType.Real,
+ cellValueType: 'number',
+ },
+ },
+ ] as unknown as never);
+
+ expect(query.countAll('"t"."__num"')).toBe('CASE WHEN "t"."__num" IS NULL THEN 0 ELSE 1 END');
+ });
+});
+
+describe('SelectQuerySqlite FROMNOW/TONOW', () => {
+ it('applies unit conversion for FROMNOW', () => {
+ const query = new SelectQuerySqlite();
+
+ const daySql = query.fromNow('date_col', "'day'");
+ const hourSql = query.fromNow('date_col', "'hour'");
+ const secondSql = query.fromNow('date_col', "'second'");
+
+ expect(daySql).toBe("(JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))");
+ expect(hourSql).toBe("((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0");
+ expect(secondSql).toBe("((JULIANDAY('now') - JULIANDAY(DATETIME(date_col)))) * 24.0 * 60 * 60");
+ });
+
+ it('keeps TONOW aligned with FROMNOW direction', () => {
+ const query = new SelectQuerySqlite();
+
+ const fromNowSql = query.fromNow('date_col', "'day'");
+ const toNowSql = query.toNow('date_col', "'day'");
+ expect(toNowSql).toBe(fromNowSql);
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.ts
new file mode 100644
index 0000000000..8d8a25d3ef
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/select-query/sqlite/select-query.sqlite.ts
@@ -0,0 +1,918 @@
+import type { ISelectFormulaConversionContext } from '../../../features/record/query-builder/sql-conversion.visitor';
+import { isTextLikeParam, resolveFormulaParamInfo } from '../../utils/formula-param-metadata.util';
+import { SelectQueryAbstract } from '../select-query.abstract';
+
+/**
+ * SQLite-specific implementation of SELECT query functions
+ * Converts Teable formula functions to SQLite SQL expressions suitable
+ * for use in SELECT statements. Unlike generated columns, these can use
+ * more functions and have different optimization strategies.
+ */
+export class SelectQuerySqlite extends SelectQueryAbstract {
+ private get tableAlias(): string | undefined {
+ const ctx = this.context as ISelectFormulaConversionContext | undefined;
+ return ctx?.tableAlias;
+ }
+
+ private getParamInfo(index?: number) {
+ return resolveFormulaParamInfo(this.currentCallMetadata, index);
+ }
+
+ private isStringLiteral(value: string): boolean {
+ const trimmed = value.trim();
+ return /^'.*'$/.test(trimmed);
+ }
+
+ private qualifySystemColumn(column: string): string {
+ const quoted = `"${column}"`;
+ const alias = this.tableAlias;
+ return alias ? `"${alias}".${quoted}` : quoted;
+ }
+
+ private isEmptyStringLiteral(value: string): boolean {
+ return value.trim() === "''";
+ }
+
+ private normalizeBlankComparable(value: string): string {
+ return `COALESCE(NULLIF(CAST((${value}) AS TEXT), ''), '')`;
+ }
+
+ private buildBlankAwareComparison(operator: '=' | '<>', left: string, right: string): string {
+ const leftIsEmptyLiteral = this.isEmptyStringLiteral(left);
+ const rightIsEmptyLiteral = this.isEmptyStringLiteral(right);
+ const leftInfo = this.getParamInfo(0);
+ const rightInfo = this.getParamInfo(1);
+ const shouldNormalize =
+ leftIsEmptyLiteral ||
+ rightIsEmptyLiteral ||
+ this.isStringLiteral(left) ||
+ this.isStringLiteral(right) ||
+ isTextLikeParam(leftInfo) ||
+ isTextLikeParam(rightInfo);
+
+ if (!shouldNormalize) {
+ return `(${left} ${operator} ${right})`;
+ }
+
+ const normalize = (value: string, isEmptyLiteral: boolean) =>
+ isEmptyLiteral ? "''" : this.normalizeBlankComparable(value);
+
+ return `(${normalize(left, leftIsEmptyLiteral)} ${operator} ${normalize(right, rightIsEmptyLiteral)})`;
+ }
+
+ private coalesceNumeric(expr: string): string {
+ return `COALESCE(CAST((${expr}) AS REAL), 0)`;
+ }
+
+ // Numeric Functions
+ sum(params: string[]): string {
+ if (params.length === 0) {
+ return '0';
+ }
+ const terms = params.map((param) => this.coalesceNumeric(param));
+ if (terms.length === 1) {
+ return terms[0];
+ }
+ return `(${terms.join(' + ')})`;
+ }
+
+ average(params: string[]): string {
+ if (params.length === 0) {
+ return '0';
+ }
+ const numerator = this.sum(params);
+ return `(${numerator}) / ${params.length}`;
+ }
+
+ max(params: string[]): string {
+ return `MAX(${this.joinParams(params)})`;
+ }
+
+ min(params: string[]): string {
+ return `MIN(${this.joinParams(params)})`;
+ }
+
+ round(value: string, precision?: string): string {
+ if (precision) {
+ return `ROUND(${value}, ${precision})`;
+ }
+ return `ROUND(${value})`;
+ }
+
+ roundUp(value: string, precision?: string): string {
+ // SQLite doesn't have CEIL with precision, implement manually
+ if (precision) {
+ return `CAST(CEIL(${value} * POWER(10, ${precision})) / POWER(10, ${precision}) AS REAL)`;
+ }
+ return `CAST(CEIL(${value}) AS INTEGER)`;
+ }
+
+ roundDown(value: string, precision?: string): string {
+ // SQLite doesn't have FLOOR with precision, implement manually
+ if (precision) {
+ return `CAST(FLOOR(${value} * POWER(10, ${precision})) / POWER(10, ${precision}) AS REAL)`;
+ }
+ return `CAST(FLOOR(${value}) AS INTEGER)`;
+ }
+
+ ceiling(value: string): string {
+ return `CAST(CEIL(${value}) AS INTEGER)`;
+ }
+
+ floor(value: string): string {
+ return `CAST(FLOOR(${value}) AS INTEGER)`;
+ }
+
+ even(value: string): string {
+ return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 0 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;
+ }
+
+ odd(value: string): string {
+ return `CASE WHEN CAST(${value} AS INTEGER) % 2 = 1 THEN CAST(${value} AS INTEGER) ELSE CAST(${value} AS INTEGER) + 1 END`;
+ }
+
+ int(value: string): string {
+ return `CAST(${value} AS INTEGER)`;
+ }
+
+ abs(value: string): string {
+ return `ABS(${value})`;
+ }
+
+ sqrt(value: string): string {
+ return `SQRT(${value})`;
+ }
+
+ power(base: string, exponent: string): string {
+ return `POWER(${base}, ${exponent})`;
+ }
+
+ exp(value: string): string {
+ return `EXP(${value})`;
+ }
+
+ log(value: string, base?: string): string {
+ if (base) {
+ // SQLite LOG is base-10, convert to natural log: ln(value) / ln(base)
+ return `(LOG(${value}) * 2.302585092994046 / (LOG(${base}) * 2.302585092994046))`;
+ }
+ // SQLite LOG is base-10, convert to natural log: LOG(value) * ln(10)
+ return `(LOG(${value}) * 2.302585092994046)`;
+ }
+
+ mod(dividend: string, divisor: string): string {
+ return `(${dividend} % ${divisor})`;
+ }
+
+ value(text: string): string {
+ return `CAST(${text} AS REAL)`;
+ }
+
+ // Text Functions
+ concatenate(params: string[]): string {
+ return `(${params.map((p) => `COALESCE(${p}, '')`).join(' || ')})`;
+ }
+
+ stringConcat(left: string, right: string): string {
+ return `(COALESCE(${left}, '') || COALESCE(${right}, ''))`;
+ }
+
+ find(searchText: string, withinText: string, startNum?: string): string {
+ if (startNum) {
+ return `CASE WHEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) > 0 THEN INSTR(SUBSTR(${withinText}, ${startNum}), ${searchText}) + ${startNum} - 1 ELSE 0 END`;
+ }
+ return `INSTR(${withinText}, ${searchText})`;
+ }
+
+ search(searchText: string, withinText: string, startNum?: string): string {
+ // Case-insensitive search
+ if (startNum) {
+ return `CASE WHEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) > 0 THEN INSTR(UPPER(SUBSTR(${withinText}, ${startNum})), UPPER(${searchText})) + ${startNum} - 1 ELSE 0 END`;
+ }
+ return `INSTR(UPPER(${withinText}), UPPER(${searchText}))`;
+ }
+
+ mid(text: string, startNum: string, numChars: string): string {
+ return `SUBSTR(${text}, ${startNum}, ${numChars})`;
+ }
+
+ left(text: string, numChars: string): string {
+ return `SUBSTR(${text}, 1, ${numChars})`;
+ }
+
+ right(text: string, numChars: string): string {
+ return `SUBSTR(${text}, -${numChars})`;
+ }
+
+ replace(oldText: string, startNum: string, numChars: string, newText: string): string {
+ return `(SUBSTR(${oldText}, 1, ${startNum} - 1) || ${newText} || SUBSTR(${oldText}, ${startNum} + ${numChars}))`;
+ }
+
+ regexpReplace(text: string, pattern: string, replacement: string): string {
+ // SQLite has limited regex support, use REPLACE for simple cases
+ return `REPLACE(${text}, ${pattern}, ${replacement})`;
+ }
+
+ substitute(text: string, oldText: string, newText: string, instanceNum?: string): string {
+ // SQLite doesn't support replacing specific instances easily
+ return `REPLACE(${text}, ${oldText}, ${newText})`;
+ }
+
+ lower(text: string): string {
+ return `LOWER(${text})`;
+ }
+
+ upper(text: string): string {
+ return `UPPER(${text})`;
+ }
+
+ rept(text: string, numTimes: string): string {
+ // SQLite doesn't have REPEAT, implement with recursive CTE or simple approach
+ return `REPLACE(HEX(ZEROBLOB(${numTimes})), '00', ${text})`;
+ }
+
+ trim(text: string): string {
+ return `TRIM(${text})`;
+ }
+
+ len(text: string): string {
+ return `LENGTH(${text})`;
+ }
+
+ t(value: string): string {
+ // SQLite T function should return numbers as numbers, not strings
+ return `CASE WHEN ${value} IS NULL THEN '' WHEN typeof(${value}) = 'text' THEN ${value} ELSE ${value} END`;
+ }
+
+ encodeUrlComponent(text: string): string {
+ // SQLite doesn't have built-in URL encoding
+ return `${text}`;
+ }
+
+ // DateTime Functions - More flexible in SELECT context
+ now(): string {
+ return `DATETIME('now')`;
+ }
+
+ private normalizeDateModifier(unitLiteral: string): {
+ unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'months' | 'years';
+ factor: number;
+ } {
+ const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return { unit: 'seconds', factor: 0.001 };
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return { unit: 'seconds', factor: 1 };
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return { unit: 'minutes', factor: 1 };
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return { unit: 'hours', factor: 1 };
+ case 'week':
+ case 'weeks':
+ return { unit: 'days', factor: 7 };
+ case 'month':
+ case 'months':
+ return { unit: 'months', factor: 1 };
+ case 'quarter':
+ case 'quarters':
+ return { unit: 'months', factor: 3 };
+ case 'year':
+ case 'years':
+ return { unit: 'years', factor: 1 };
+ case 'day':
+ case 'days':
+ default:
+ return { unit: 'days', factor: 1 };
+ }
+ }
+
+ private normalizeDiffUnit(
+ unitLiteral: string
+ ): 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' {
+ const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ return 'millisecond';
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return 'second';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return 'minute';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return 'hour';
+ case 'week':
+ case 'weeks':
+ return 'week';
+ case 'month':
+ case 'months':
+ return 'month';
+ case 'quarter':
+ case 'quarters':
+ return 'quarter';
+ case 'year':
+ case 'years':
+ return 'year';
+ default:
+ return 'day';
+ }
+ }
+
+ private normalizeTruncateFormat(unitLiteral: string): string {
+ const normalized = unitLiteral.replace(/^'|'$/g, '').trim().toLowerCase();
+ switch (normalized) {
+ case 'millisecond':
+ case 'milliseconds':
+ case 'ms':
+ case 'second':
+ case 'seconds':
+ case 's':
+ case 'sec':
+ case 'secs':
+ return '%Y-%m-%d %H:%M:%S';
+ case 'minute':
+ case 'minutes':
+ case 'min':
+ case 'mins':
+ return '%Y-%m-%d %H:%M';
+ case 'hour':
+ case 'hours':
+ case 'h':
+ case 'hr':
+ case 'hrs':
+ return '%Y-%m-%d %H';
+ case 'week':
+ case 'weeks':
+ return '%Y-%W';
+ case 'month':
+ case 'months':
+ return '%Y-%m';
+ case 'year':
+ case 'years':
+ return '%Y';
+ case 'day':
+ case 'days':
+ default:
+ return '%Y-%m-%d';
+ }
+ }
+
+ today(): string {
+ return `DATE('now')`;
+ }
+
+ dateAdd(date: string, count: string, unit: string): string {
+ const { unit: modifierUnit, factor } = this.normalizeDateModifier(unit);
+ const scaledCount = factor === 1 ? `(${count})` : `(${count}) * ${factor}`;
+ return `DATETIME(${date}, (${scaledCount}) || ' ${modifierUnit}')`;
+ }
+
+ datestr(date: string): string {
+ return `DATE(${date})`;
+ }
+
+ private buildMonthDiff(startDate: string, endDate: string): string {
+ const startYear = `CAST(STRFTIME('%Y', ${startDate}) AS INTEGER)`;
+ const endYear = `CAST(STRFTIME('%Y', ${endDate}) AS INTEGER)`;
+ const startMonth = `CAST(STRFTIME('%m', ${startDate}) AS INTEGER)`;
+ const endMonth = `CAST(STRFTIME('%m', ${endDate}) AS INTEGER)`;
+ const startDay = `CAST(STRFTIME('%d', ${startDate}) AS INTEGER)`;
+ const endDay = `CAST(STRFTIME('%d', ${endDate}) AS INTEGER)`;
+ const startLastDay = `CAST(STRFTIME('%d', DATE(${startDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;
+ const endLastDay = `CAST(STRFTIME('%d', DATE(${endDate}, 'start of month', '+1 month', '-1 day')) AS INTEGER)`;
+
+ const baseMonths = `((${startYear} - ${endYear}) * 12 + (${startMonth} - ${endMonth}))`;
+ const adjustDown = `(CASE WHEN ${baseMonths} > 0 AND ${startDay} < ${endDay} AND ${startDay} < ${startLastDay} THEN 1 ELSE 0 END)`;
+ const adjustUp = `(CASE WHEN ${baseMonths} < 0 AND ${startDay} > ${endDay} AND ${endDay} < ${endLastDay} THEN 1 ELSE 0 END)`;
+
+ return `(${baseMonths} - ${adjustDown} + ${adjustUp})`;
+ }
+
+ datetimeDiff(startDate: string, endDate: string, unit: string): string {
+ const baseDiffDays = `(JULIANDAY(${startDate}) - JULIANDAY(${endDate}))`;
+ switch (this.normalizeDiffUnit(unit)) {
+ case 'millisecond':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;
+ case 'second':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60`;
+ case 'minute':
+ return `(${baseDiffDays}) * 24.0 * 60`;
+ case 'hour':
+ return `(${baseDiffDays}) * 24.0`;
+ case 'week':
+ return `(${baseDiffDays}) / 7.0`;
+ case 'month':
+ return this.buildMonthDiff(startDate, endDate);
+ case 'quarter':
+ return `${this.buildMonthDiff(startDate, endDate)} / 3.0`;
+ case 'year': {
+ const monthDiff = this.buildMonthDiff(startDate, endDate);
+ return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;
+ }
+ case 'day':
+ default:
+ return `${baseDiffDays}`;
+ }
+ }
+
+ datetimeFormat(date: string, format: string): string {
+ return `STRFTIME(${format}, ${date})`;
+ }
+
+ datetimeParse(dateString: string, _format?: string): string {
+ // SQLite doesn't have direct parsing with custom formats
+ return `DATETIME(${dateString})`;
+ }
+
+ day(date: string): string {
+ return `CAST(STRFTIME('%d', ${date}) AS INTEGER)`;
+ }
+
+ private buildNowDiffByUnit(nowExpr: string, dateExpr: string, unit: string): string {
+ const baseDiffDays = `(JULIANDAY(${nowExpr}) - JULIANDAY(${dateExpr}))`;
+ switch (this.normalizeDiffUnit(unit)) {
+ case 'millisecond':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60 * 1000`;
+ case 'second':
+ return `(${baseDiffDays}) * 24.0 * 60 * 60`;
+ case 'minute':
+ return `(${baseDiffDays}) * 24.0 * 60`;
+ case 'hour':
+ return `(${baseDiffDays}) * 24.0`;
+ case 'week':
+ return `(${baseDiffDays}) / 7.0`;
+ case 'month':
+ return this.buildMonthDiff(nowExpr, dateExpr);
+ case 'quarter':
+ return `${this.buildMonthDiff(nowExpr, dateExpr)} / 3.0`;
+ case 'year': {
+ const monthDiff = this.buildMonthDiff(nowExpr, dateExpr);
+ return `CAST((${monthDiff}) / 12.0 AS INTEGER)`;
+ }
+ case 'day':
+ default:
+ return `${baseDiffDays}`;
+ }
+ }
+
+ fromNow(date: string, unit = 'day'): string {
+ return this.buildNowDiffByUnit("'now'", `DATETIME(${date})`, unit);
+ }
+
+ hour(date: string): string {
+ return `CAST(STRFTIME('%H', ${date}) AS INTEGER)`;
+ }
+
+ isAfter(date1: string, date2: string): string {
+ return `DATETIME(${date1}) > DATETIME(${date2})`;
+ }
+
+ isBefore(date1: string, date2: string): string {
+ return `DATETIME(${date1}) < DATETIME(${date2})`;
+ }
+
+ isSame(date1: string, date2: string, unit?: string): string {
+ if (unit) {
+ const trimmed = unit.trim();
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
+ const format = this.normalizeTruncateFormat(trimmed.slice(1, -1));
+ return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;
+ }
+ const format = this.normalizeTruncateFormat(unit);
+ return `STRFTIME('${format}', ${date1}) = STRFTIME('${format}', ${date2})`;
+ }
+ return `DATETIME(${date1}) = DATETIME(${date2})`;
+ }
+
+ lastModifiedTime(): string {
+ return this.qualifySystemColumn('__last_modified_time');
+ }
+
+ minute(date: string): string {
+ return `CAST(STRFTIME('%M', ${date}) AS INTEGER)`;
+ }
+
+ month(date: string): string {
+ return `CAST(STRFTIME('%m', ${date}) AS INTEGER)`;
+ }
+
+ second(date: string): string {
+ return `CAST(STRFTIME('%S', ${date}) AS INTEGER)`;
+ }
+
+ timestr(date: string): string {
+ return `TIME(${date})`;
+ }
+
+ toNow(date: string, unit = 'day'): string {
+ return this.fromNow(date, unit);
+ }
+
+ weekNum(date: string): string {
+ return `CAST(STRFTIME('%W', ${date}) AS INTEGER)`;
+ }
+
+ weekday(date: string, startDayOfWeek?: string): string {
+ // SQLite STRFTIME('%w') returns 0-6 (Sunday=0), but we need 1-7 (Sunday=1)
+ const weekdaySql = `CAST(STRFTIME('%w', ${date}) AS INTEGER) + 1`;
+ if (!startDayOfWeek) {
+ return weekdaySql;
+ }
+
+ const normalizedStartDay = `LOWER(TRIM(COALESCE(CAST(${startDayOfWeek} AS TEXT), '')))`;
+ const mondayWeekdaySql = `(CASE WHEN (${weekdaySql}) = 1 THEN 7 ELSE (${weekdaySql}) - 1 END)`;
+ return `CASE WHEN ${normalizedStartDay} = 'monday' THEN ${mondayWeekdaySql} ELSE ${weekdaySql} END`;
+ }
+
+ workday(startDate: string, days: string, holidayStr?: string): string {
+ const dayCountSql = `CAST(${this.coalesceNumeric(days)} AS INTEGER)`;
+ const holidayTextSql = holidayStr ? `COALESCE(CAST(${holidayStr} AS TEXT), '')` : `''`;
+
+ return `(
+ WITH RECURSIVE
+ params AS (
+ SELECT DATE(${startDate}) AS start_date, ${dayCountSql} AS day_count, ${holidayTextSql} AS holiday_text
+ ),
+ split(rest, part) AS (
+ SELECT (SELECT holiday_text FROM params), ''
+ UNION ALL
+ SELECT
+ CASE WHEN INSTR(rest, ',') = 0 THEN '' ELSE SUBSTR(rest, INSTR(rest, ',') + 1) END,
+ TRIM(CASE WHEN INSTR(rest, ',') = 0 THEN rest ELSE SUBSTR(rest, 1, INSTR(rest, ',') - 1) END)
+ FROM split
+ WHERE rest <> ''
+ ),
+ holiday_dates AS (
+ SELECT DISTINCT DATE(SUBSTR(part, 1, 10)) AS holiday_date
+ FROM split
+ WHERE part <> ''
+ AND part GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'
+ AND DATE(SUBSTR(part, 1, 10)) = SUBSTR(part, 1, 10)
+ ),
+ seq(n) AS (
+ SELECT 1
+ UNION ALL
+ SELECT n + 1
+ FROM seq
+ WHERE n < (SELECT ABS(day_count) * 7 + 366 FROM params)
+ ),
+ candidates AS (
+ SELECT
+ DATE(
+ p.start_date,
+ PRINTF('%+d day', CASE WHEN p.day_count >= 0 THEN seq.n ELSE -seq.n END)
+ ) AS candidate_date,
+ seq.n
+ FROM params p
+ CROSS JOIN seq
+ ),
+ workdays AS (
+ SELECT c.candidate_date, c.n
+ FROM candidates c
+ LEFT JOIN holiday_dates h ON h.holiday_date = c.candidate_date
+ WHERE CAST(STRFTIME('%w', c.candidate_date) AS INTEGER) NOT IN (0, 6)
+ AND h.holiday_date IS NULL
+ ORDER BY c.n
+ )
+ SELECT CASE
+ WHEN p.day_count = 0 THEN p.start_date
+ ELSE (
+ SELECT w.candidate_date
+ FROM workdays w
+ LIMIT 1 OFFSET ABS(p.day_count) - 1
+ )
+ END
+ FROM params p
+ )`;
+ }
+
+ workdayDiff(startDate: string, endDate: string): string {
+ return `CAST((JULIANDAY(${endDate}) - JULIANDAY(${startDate})) AS INTEGER)`;
+ }
+
+ year(date: string): string {
+ return `CAST(STRFTIME('%Y', ${date}) AS INTEGER)`;
+ }
+
+ createdTime(): string {
+ return this.qualifySystemColumn('__created_time');
+ }
+
+ // Logical Functions
+ private truthinessScore(value: string): string {
+ const wrapped = `(${value})`;
+ const valueType = `TYPEOF${wrapped}`;
+ return `CASE
+ WHEN ${wrapped} IS NULL THEN 0
+ WHEN ${valueType} = 'integer' OR ${valueType} = 'real' THEN (${wrapped}) != 0
+ WHEN ${valueType} = 'text' THEN (${wrapped} != '' AND LOWER(${wrapped}) != 'null')
+ ELSE (${wrapped}) IS NOT NULL AND ${wrapped} != 'null'
+ END`;
+ }
+
+ if(condition: string, valueIfTrue: string, valueIfFalse: string): string {
+ const truthiness = this.truthinessScore(condition);
+ return `CASE WHEN (${truthiness}) = 1 THEN ${valueIfTrue} ELSE ${valueIfFalse} END`;
+ }
+
+ and(params: string[]): string {
+ return `(${params.map((p) => `(${p})`).join(' AND ')})`;
+ }
+
+ or(params: string[]): string {
+ return `(${params.map((p) => `(${p})`).join(' OR ')})`;
+ }
+
+ not(value: string): string {
+ return `NOT (${value})`;
+ }
+
+ xor(params: string[]): string {
+ if (params.length === 2) {
+ return `((${params[0]}) AND NOT (${params[1]})) OR (NOT (${params[0]}) AND (${params[1]}))`;
+ }
+ return `(${params.map((p) => `CASE WHEN ${p} THEN 1 ELSE 0 END`).join(' + ')}) % 2 = 1`;
+ }
+
+ blank(): string {
+ // SQLite BLANK function should return null instead of empty string
+ return `NULL`;
+ }
+
+ error(_message: string): string {
+ // SQLite doesn't have a direct error function, use a failing expression
+ return `(1/0)`;
+ }
+
+ isError(_value: string): string {
+ return `0`;
+ }
+
+ switch(
+ expression: string,
+ cases: Array<{ case: string; result: string }>,
+ defaultResult?: string
+ ): string {
+ let sql = `CASE ${expression}`;
+ for (const caseItem of cases) {
+ sql += ` WHEN ${caseItem.case} THEN ${caseItem.result}`;
+ }
+ if (defaultResult) {
+ sql += ` ELSE ${defaultResult}`;
+ }
+ sql += ` END`;
+ return sql;
+ }
+
+ // Array Functions - Limited in SQLite
+ count(params: string[]): string {
+ return `COUNT(${this.joinParams(params)})`;
+ }
+
+ countA(params: string[]): string {
+ return `COUNT(${this.joinParams(params.map((p) => `CASE WHEN ${p} IS NOT NULL THEN 1 END`))})`;
+ }
+
+ countAll(value: string): string {
+ const paramInfo = this.getParamInfo(0);
+ if (paramInfo.isJsonField || paramInfo.isMultiValueField) {
+ const baseExpr =
+ paramInfo.isFieldReference && paramInfo.fieldDbName
+ ? this.tableAlias
+ ? `"${this.tableAlias}"."${paramInfo.fieldDbName}"`
+ : `"${paramInfo.fieldDbName}"`
+ : value;
+ return `CASE
+ WHEN ${baseExpr} IS NULL THEN 0
+ WHEN json_valid(${baseExpr}) AND json_type(${baseExpr}) = 'array' THEN COALESCE(json_array_length(${baseExpr}), 0)
+ WHEN json_valid(${baseExpr}) AND json_type(${baseExpr}) = 'null' THEN 0
+ ELSE 1
+ END`;
+ }
+
+ return `CASE WHEN ${value} IS NULL THEN 0 ELSE 1 END`;
+ }
+
+ private buildJsonArrayUnion(
+ arrays: string[],
+ opts?: { filterNulls?: boolean; withOrdinal?: boolean }
+ ): string {
+ const selects = arrays.map((array, index) => {
+ const base = `SELECT value, ${index} AS arg_index, CAST(key AS INTEGER) AS ord FROM json_each(COALESCE(${array}, '[]'))`;
+ const whereClause = opts?.filterNulls
+ ? " WHERE value IS NOT NULL AND value != 'null' AND value != ''"
+ : '';
+ return `${base}${whereClause}`;
+ });
+
+ if (selects.length === 0) {
+ return 'SELECT NULL AS value, 0 AS arg_index, 0 AS ord WHERE 0';
+ }
+
+ return selects.join(' UNION ALL ');
+ }
+
+ arrayJoin(array: string, separator?: string): string {
+ const sep = separator || ',';
+ // SQLite JSON array join using json_each with stable ordering by key
+ return `(SELECT GROUP_CONCAT(value, ${sep}) FROM json_each(${array}) ORDER BY key)`;
+ }
+
+ arrayUnique(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true, filterNulls: true });
+ return `COALESCE(
+ '[' || (
+ SELECT GROUP_CONCAT(json_quote(value))
+ FROM (
+ SELECT value, ROW_NUMBER() OVER (PARTITION BY value ORDER BY arg_index, ord) AS rn, arg_index, ord
+ FROM (${unionQuery}) AS combined
+ )
+ WHERE rn = 1
+ ORDER BY arg_index, ord
+ ) || ']',
+ '[]'
+ )`;
+ }
+
+ arrayFlatten(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, { withOrdinal: true });
+ return `COALESCE(
+ '[' || (
+ SELECT GROUP_CONCAT(json_quote(value))
+ FROM (${unionQuery}) AS combined
+ ORDER BY arg_index, ord
+ ) || ']',
+ '[]'
+ )`;
+ }
+
+ arrayCompact(arrays: string[]): string {
+ const unionQuery = this.buildJsonArrayUnion(arrays, {
+ filterNulls: true,
+ withOrdinal: true,
+ });
+ return `COALESCE(
+ '[' || (
+ SELECT GROUP_CONCAT(json_quote(value))
+ FROM (${unionQuery}) AS combined
+ ORDER BY arg_index, ord
+ ) || ']',
+ '[]'
+ )`;
+ }
+
+ // System Functions
+ recordId(): string {
+ return this.qualifySystemColumn('__id');
+ }
+
+ autoNumber(): string {
+ return this.qualifySystemColumn('__auto_number');
+ }
+
+ textAll(value: string): string {
+ return `CAST(${value} AS TEXT)`;
+ }
+
+ // Binary Operations
+ add(left: string, right: string): string {
+ return `(${left} + ${right})`;
+ }
+
+ subtract(left: string, right: string): string {
+ return `(${left} - ${right})`;
+ }
+
+ multiply(left: string, right: string): string {
+ return `(${left} * ${right})`;
+ }
+
+ divide(left: string, right: string): string {
+ return `(${left} / ${right})`;
+ }
+
+ modulo(left: string, right: string): string {
+ return `(${left} % ${right})`;
+ }
+
+ // Comparison Operations
+ equal(left: string, right: string): string {
+ return this.buildBlankAwareComparison('=', left, right);
+ }
+
+ notEqual(left: string, right: string): string {
+ return this.buildBlankAwareComparison('<>', left, right);
+ }
+
+ greaterThan(left: string, right: string): string {
+ return `(${left} > ${right})`;
+ }
+
+ lessThan(left: string, right: string): string {
+ return `(${left} < ${right})`;
+ }
+
+ greaterThanOrEqual(left: string, right: string): string {
+ return `(${left} >= ${right})`;
+ }
+
+ lessThanOrEqual(left: string, right: string): string {
+ return `(${left} <= ${right})`;
+ }
+
+ // Logical Operations
+ logicalAnd(left: string, right: string): string {
+ return `(${left} AND ${right})`;
+ }
+
+ logicalOr(left: string, right: string): string {
+ return `(${left} OR ${right})`;
+ }
+
+ bitwiseAnd(left: string, right: string): string {
+ return `(${left} & ${right})`;
+ }
+
+ // Unary Operations
+ unaryMinus(value: string): string {
+ return `(-${value})`;
+ }
+
+ // Field Reference
+ fieldReference(_fieldId: string, columnName: string): string {
+ return `"${columnName}"`;
+ }
+
+ // Literals
+ stringLiteral(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`;
+ }
+
+ numberLiteral(value: number): string {
+ return value.toString();
+ }
+
+ booleanLiteral(value: boolean): string {
+ return value ? '1' : '0';
+ }
+
+ nullLiteral(): string {
+ return 'NULL';
+ }
+
+ // Utility methods for type conversion and validation
+ castToNumber(value: string): string {
+ return `CAST(${value} AS REAL)`;
+ }
+
+ castToString(value: string): string {
+ return `CAST(${value} AS TEXT)`;
+ }
+
+ castToBoolean(value: string): string {
+ return `CASE WHEN ${value} THEN 1 ELSE 0 END`;
+ }
+
+ castToDate(value: string): string {
+ return `DATETIME(${value})`;
+ }
+
+ // Handle null values and type checking
+ isNull(value: string): string {
+ return `${value} IS NULL`;
+ }
+
+ coalesce(params: string[]): string {
+ return `COALESCE(${this.joinParams(params)})`;
+ }
+
+ // Parentheses for grouping
+ parentheses(expression: string): string {
+ return `(${expression})`;
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.abstract.ts b/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.abstract.ts
index 46786f34de..b0f10caac4 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.abstract.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.abstract.ts
@@ -1,19 +1,35 @@
import { InternalServerErrorException } from '@nestjs/common';
+import type { FieldCore } from '@teable/core';
import { SortFunc } from '@teable/core';
import type { Knex } from 'knex';
-import type { IFieldInstance } from '../../../features/field/model/factory';
+import type { IRecordQuerySortContext } from '../../../features/record/query-builder/record-query-builder.interface';
import type { ISortFunctionInterface } from './sort-function.interface';
export abstract class AbstractSortFunction implements ISortFunctionInterface {
- protected columnName: string;
+ protected columnName?: string;
constructor(
protected readonly knex: Knex,
- protected readonly field: IFieldInstance
+ protected readonly field: FieldCore,
+ protected readonly context?: IRecordQuerySortContext
) {
- const { dbFieldName } = this.field;
+ const { dbFieldName, id } = field;
- this.columnName = dbFieldName;
+ const selection = context?.selectionMap.get(id);
+ const normalizedSelection =
+ selection !== undefined && selection !== null
+ ? this.normalizeSelection(selection)
+ : undefined;
+ if (this.isNullConstant(normalizedSelection)) {
+ this.columnName = undefined;
+ return;
+ }
+ if (normalizedSelection) {
+ this.columnName = normalizedSelection;
+ return;
+ }
+ const quotedIdentifier = this.quoteIdentifier(dbFieldName);
+ this.columnName = this.isNullConstant(quotedIdentifier) ? undefined : quotedIdentifier;
}
compiler(builderClient: Knex.QueryBuilder, sortFunc: SortFunc) {
@@ -30,17 +46,89 @@ export abstract class AbstractSortFunction implements ISortFunctionInterface {
return chosenHandler(builderClient);
}
+ generateSQL(sortFunc: SortFunc): string | undefined {
+ const functionHandlers = {
+ [SortFunc.Asc]: this.getAscSQL,
+ [SortFunc.Desc]: this.getDescSQL,
+ };
+ const chosenHandler = functionHandlers[sortFunc].bind(this);
+
+ if (!chosenHandler) {
+ throw new InternalServerErrorException(`Unknown function ${sortFunc} for sort`);
+ }
+
+ return chosenHandler();
+ }
+
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`?? ASC NULLS FIRST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ builderClient.orderByRaw(`${this.columnName} ASC NULLS FIRST`);
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`?? DESC NULLS LAST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);
return builderClient;
}
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();
+ }
+
protected createSqlPlaceholders(values: unknown[]): string {
return values.map(() => '?').join(',');
}
+
+ private normalizeSelection(selection: unknown): string | undefined {
+ if (typeof selection === 'string') {
+ return selection;
+ }
+ if (selection && typeof (selection as Knex.Raw).toQuery === 'function') {
+ return (selection as Knex.Raw).toQuery();
+ }
+ if (selection && typeof (selection as Knex.Raw).toSQL === 'function') {
+ const { sql } = (selection as Knex.Raw).toSQL();
+ if (sql) {
+ return sql;
+ }
+ }
+ return undefined;
+ }
+
+ private quoteIdentifier(identifier: string): string {
+ if (!identifier) {
+ return identifier;
+ }
+ if (identifier.startsWith('"') && identifier.endsWith('"')) {
+ return identifier;
+ }
+ const escaped = identifier.replace(/"/g, '""');
+ return `"${escaped}"`;
+ }
+
+ private isNullConstant(selection?: string): boolean {
+ if (!selection) {
+ return false;
+ }
+ const trimmed = selection.trim().toUpperCase();
+ if (trimmed === 'NULL') {
+ return true;
+ }
+ return trimmed.startsWith('NULL::');
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.interface.ts b/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.interface.ts
index 6cf8efc1ec..3216844056 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.interface.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/function/sort-function.interface.ts
@@ -5,4 +5,6 @@ export type ISortFunctionHandler = (builderClient: Knex.QueryBuilder) => Knex.Qu
export interface ISortFunctionInterface {
asc: ISortFunctionHandler;
desc: ISortFunctionHandler;
+ getAscSQL: () => string | undefined;
+ getDescSQL: () => string | undefined;
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-datetime-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-datetime-sort.adapter.ts
index 7e19688007..11b8968765 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-datetime-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-datetime-sort.adapter.ts
@@ -1,14 +1,156 @@
+import { TimeFormatting, type DateFormattingPreset, type IDateFieldOptions } from '@teable/core';
import type { Knex } from 'knex';
+import { getPostgresDateTimeFormatString } from '../../../group-query/format-string';
import { SortFunctionPostgres } from '../sort-query.function';
export class MultipleDateTimeSortAdapter extends SortFunctionPostgres {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`(??::jsonb ->> 0)::TIMESTAMPTZ ASC NULLS FIRST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ let orderByColumn;
+ if (time === TimeFormatting.None) {
+ orderByColumn = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ ASC NULLS FIRST,
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ ASC NULLS FIRST
+ `,
+ [timeZone, formatString, timeZone, formatString]
+ );
+ } else {
+ orderByColumn = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ ASC NULLS FIRST,
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ ASC NULLS FIRST
+ `
+ );
+ }
+ builderClient.orderByRaw(orderByColumn);
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`(??::jsonb ->> 0)::TIMESTAMPTZ DESC NULLS LAST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ let orderByColumn;
+ if (time === TimeFormatting.None) {
+ orderByColumn = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ DESC NULLS LAST,
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ DESC NULLS LAST
+ `,
+ [timeZone, formatString, timeZone, formatString]
+ );
+ } else {
+ orderByColumn = this.knex.raw(
+ `
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ DESC NULLS LAST,
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ DESC NULLS LAST
+ `
+ );
+ }
+ builderClient.orderByRaw(orderByColumn);
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(
+ `
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ ASC NULLS FIRST,
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ ASC NULLS FIRST
+ `,
+ [timeZone, formatString, timeZone, formatString]
+ )
+ .toQuery();
+ } else {
+ return this.knex
+ .raw(
+ `
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ ASC NULLS FIRST,
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ ASC NULLS FIRST
+ `
+ )
+ .toQuery();
+ }
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(
+ `
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ DESC NULLS LAST,
+ (SELECT to_jsonb(array_agg(TO_CHAR(TIMEZONE(?, CAST(elem AS timestamp with time zone)), ?)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ DESC NULLS LAST
+ `,
+ [timeZone, formatString, timeZone, formatString]
+ )
+ .toQuery();
+ } else {
+ return this.knex
+ .raw(
+ `
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem) ->> 0
+ DESC NULLS LAST,
+ (SELECT to_jsonb(array_agg(elem))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem)
+ DESC NULLS LAST
+ `
+ )
+ .toQuery();
+ }
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-json-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-json-sort.adapter.ts
index 93dc8ec7ef..d9402363ba 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-json-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-json-sort.adapter.ts
@@ -1,33 +1,132 @@
+import type { ISelectFieldOptions } from '@teable/core';
import { FieldType } from '@teable/core';
import type { Knex } from 'knex';
+import { isUserOrLink } from '../../../../utils/is-user-or-link';
import { SortFunctionPostgres } from '../sort-query.function';
export class MultipleJsonSortAdapter extends SortFunctionPostgres {
+ /**
+ * Use the first choice (array[0]) to compute choice index.
+ * If not an array, fall back to comparing the raw scalar text.
+ */
+ private firstChoiceIndexExpr(optionSets: string[]) {
+ const arrayLiteral = `ARRAY[${this.createSqlPlaceholders(optionSets)}]`;
+ const sql = `CASE
+ WHEN ${this.columnName} IS NULL THEN NULL
+ WHEN jsonb_typeof(${this.columnName}::jsonb) = 'array'
+ THEN ARRAY_POSITION(${arrayLiteral}, jsonb_path_query_first(${this.columnName}::jsonb, '$[0]') #>> '{}')
+ ELSE ARRAY_POSITION(${arrayLiteral}, ${this.columnName}::text)
+ END`;
+ // arrayLiteral is used twice, so duplicate the bindings to satisfy both occurrences
+ const bindings = [...optionSets, ...optionSets];
+ return { sql, bindings };
+ }
+
+ private orderByMultiSelect(
+ builderClient: Knex.QueryBuilder,
+ direction: 'ASC' | 'DESC',
+ nulls: 'FIRST' | 'LAST'
+ ) {
+ if (!this.columnName) return builderClient;
+ const { choices } = this.field.options as ISelectFieldOptions;
+ if (!choices.length) return builderClient;
+ const optionSets = choices.map(({ name }) => name);
+ const { sql, bindings } = this.firstChoiceIndexExpr(optionSets);
+ builderClient.orderByRaw(`${sql} ${direction} NULLS ${nulls}`, bindings);
+ // Stable tie-breaker to make ordering deterministic when min index is equal
+ builderClient.orderByRaw(`${this.columnName}::jsonb::text ${direction} NULLS ${nulls}`);
+ return builderClient;
+ }
+
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isUserOrLink(type)) {
builderClient.orderByRaw(
- `jsonb_path_query_array(??::jsonb, '$[*].title')::text ASC NULLS FIRST`,
- [this.columnName]
+ `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text ASC NULLS FIRST`
);
+ } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {
+ return this.orderByMultiSelect(builderClient, 'ASC', 'FIRST');
} else {
- builderClient.orderByRaw(`??::jsonb ->> 0 ASC NULLS FIRST`, [this.columnName]);
+ builderClient.orderByRaw(
+ `${this.columnName}::jsonb ->> 0 ASC NULLS FIRST, jsonb_array_length(${this.columnName}::jsonb) ASC`
+ );
}
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
+ if (isUserOrLink(type)) {
builderClient.orderByRaw(
- `jsonb_path_query_array(??::jsonb, '$[*].title')::text DESC NULLS LAST`,
- [this.columnName]
+ `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text DESC NULLS LAST`
);
+ } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {
+ return this.orderByMultiSelect(builderClient, 'DESC', 'LAST');
} else {
- builderClient.orderByRaw(`??::jsonb ->> 0 DESC NULLS LAST`, [this.columnName]);
+ builderClient.orderByRaw(
+ `${this.columnName}::jsonb ->> 0 DESC NULLS LAST, jsonb_array_length(${this.columnName}::jsonb) DESC`
+ );
}
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { type } = this.field;
+
+ if (isUserOrLink(type)) {
+ return this.knex
+ .raw(
+ `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text ASC NULLS FIRST`
+ )
+ .toQuery();
+ } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {
+ const { choices } = this.field.options as ISelectFieldOptions;
+ const optionSets = choices.map(({ name }) => name);
+ const { sql, bindings } = this.firstChoiceIndexExpr(optionSets);
+ return this.knex.raw(`${sql} ASC NULLS FIRST`, bindings).toQuery();
+ } else {
+ return this.knex
+ .raw(
+ `${this.columnName}::jsonb ->> 0 ASC NULLS FIRST, jsonb_array_length(${this.columnName}::jsonb) ASC`
+ )
+ .toQuery();
+ }
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { type } = this.field;
+
+ if (isUserOrLink(type)) {
+ return this.knex
+ .raw(
+ `jsonb_path_query_array(${this.columnName}::jsonb, '$[*].title')::text DESC NULLS LAST`
+ )
+ .toQuery();
+ } else if ([FieldType.SingleSelect, FieldType.MultipleSelect].includes(type)) {
+ const { choices } = this.field.options as ISelectFieldOptions;
+ const optionSets = choices.map(({ name }) => name);
+ const { sql, bindings } = this.firstChoiceIndexExpr(optionSets);
+ return this.knex.raw(`${sql} DESC NULLS LAST`, bindings).toQuery();
+ } else {
+ return this.knex
+ .raw(
+ `${this.columnName}::jsonb ->> 0 DESC NULLS LAST, jsonb_array_length(${this.columnName}::jsonb) DESC`
+ )
+ .toQuery();
+ }
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-number-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-number-sort.adapter.ts
index ac5a9f3a1d..2fdcce78bb 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-number-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/multiple-value/multiple-number-sort.adapter.ts
@@ -1,14 +1,74 @@
+import type { INumberFieldOptions } from '@teable/core';
import type { Knex } from 'knex';
import { SortFunctionPostgres } from '../sort-query.function';
export class MultipleNumberSortAdapter extends SortFunctionPostgres {
+ private buildRoundedFirstElementExpr(precision: number) {
+ return this.knex.raw(
+ `
+ ROUND((jsonb_path_query_first(${this.columnName}::jsonb, '$[0]') #>> '{}')::numeric, ?::int)
+ `,
+ [precision]
+ );
+ }
+
+ private buildRoundedArrayExpr(precision: number) {
+ return this.knex.raw(
+ `
+ (
+ SELECT to_jsonb(array_agg(ROUND(elem::numeric, ?::int)))
+ FROM jsonb_array_elements_text(${this.columnName}::jsonb) as elem
+ )
+ `,
+ [precision]
+ );
+ }
+
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`(??::jsonb ->> 0)::bigint ASC NULLS FIRST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ builderClient.orderByRaw(`${firstElementExpr} ASC NULLS FIRST`);
+ builderClient.orderByRaw(`${arrayExpr} ASC NULLS FIRST`);
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`(??::jsonb ->> 0)::bigint DESC NULLS LAST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ builderClient.orderByRaw(`${firstElementExpr} DESC NULLS LAST`);
+ builderClient.orderByRaw(`${arrayExpr} DESC NULLS LAST`);
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ return `${firstElementExpr} ASC NULLS FIRST, ${arrayExpr} ASC NULLS FIRST`;
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ return `${firstElementExpr} DESC NULLS LAST, ${arrayExpr} DESC NULLS LAST`;
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/date-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/date-sort.adapter.ts
new file mode 100644
index 0000000000..19b88ed5c2
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/date-sort.adapter.ts
@@ -0,0 +1,86 @@
+import { type IDateFieldOptions, type DateFormattingPreset, TimeFormatting } from '@teable/core';
+import type { Knex } from 'knex';
+import { getPostgresDateTimeFormatString } from '../../../group-query/format-string';
+import { SortFunctionPostgres } from '../sort-query.function';
+
+export class DateSortAdapter extends SortFunctionPostgres {
+ asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ if (time === TimeFormatting.None) {
+ builderClient.orderByRaw(`TO_CHAR(TIMEZONE(?, ${this.columnName}), ?) ASC NULLS FIRST`, [
+ timeZone,
+ formatString,
+ ]);
+ } else {
+ builderClient.orderByRaw(`${this.columnName} ASC NULLS FIRST`);
+ }
+
+ return builderClient;
+ }
+
+ desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ if (time === TimeFormatting.None) {
+ builderClient.orderByRaw(
+ `TO_CHAR(TIMEZONE(?, ${(this, this.columnName)}), ?) DESC NULLS LAST`,
+ [timeZone, formatString]
+ );
+ } else {
+ builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);
+ }
+
+ return builderClient;
+ }
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(`TO_CHAR(TIMEZONE(?, ${this.columnName}), ?) ASC NULLS FIRST`, [
+ timeZone,
+ formatString,
+ ])
+ .toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();
+ }
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getPostgresDateTimeFormatString(date as DateFormattingPreset, time);
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(`TO_CHAR(TIMEZONE(?, ${this.columnName}), ?) DESC NULLS LAST`, [
+ timeZone,
+ formatString,
+ ])
+ .toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();
+ }
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/json-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/json-sort.adapter.ts
index ba549c8628..51df849701 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/json-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/json-sort.adapter.ts
@@ -1,27 +1,59 @@
-import { FieldType } from '@teable/core';
import type { Knex } from 'knex';
+import { isUserOrLink } from '../../../../utils/is-user-or-link';
import { SortFunctionPostgres } from '../sort-query.function';
export class JsonSortAdapter extends SortFunctionPostgres {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
- builderClient.orderByRaw(`??::jsonb ->> 'title' ASC NULLS FIRST`, [this.columnName]);
+ if (isUserOrLink(type)) {
+ builderClient.orderByRaw(`${this.columnName}::jsonb ->> 'title' ASC NULLS FIRST`);
} else {
- builderClient.orderByRaw(`??::jsonb ASC NULLS FIRST`, [this.columnName]);
+ builderClient.orderByRaw(`${this.columnName}::jsonb ASC NULLS FIRST`);
}
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
- builderClient.orderByRaw(`??::jsonb ->> 'title' DESC NULLS LAST`, [this.columnName]);
+ if (isUserOrLink(type)) {
+ builderClient.orderByRaw(`${this.columnName}::jsonb ->> 'title' DESC NULLS LAST`);
} else {
- builderClient.orderByRaw(`??::jsonb DESC NULLS LAST`, [this.columnName]);
+ builderClient.orderByRaw(`${this.columnName}::jsonb DESC NULLS LAST`);
}
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { type } = this.field;
+
+ if (isUserOrLink(type)) {
+ return this.knex.raw(`${this.columnName}::jsonb ->> 'title' ASC NULLS FIRST`).toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName}::jsonb ASC NULLS FIRST`).toQuery();
+ }
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { type } = this.field;
+
+ if (isUserOrLink(type)) {
+ return this.knex.raw(`${this.columnName}::jsonb ->> 'title' DESC NULLS LAST`).toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName}::jsonb DESC NULLS LAST`).toQuery();
+ }
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/string-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/string-sort.adapter.ts
index 18ab78fef7..aad73455ef 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/string-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/single-value/string-sort.adapter.ts
@@ -1,40 +1,93 @@
+import type { ISelectFieldOptions } from '@teable/core';
import { FieldType } from '@teable/core';
import type { Knex } from 'knex';
-import type { SingleSelectOptionsDto } from '../../../../features/field/model/field-dto/single-select-field.dto';
import { SortFunctionPostgres } from '../sort-query.function';
export class StringSortAdapter extends SortFunctionPostgres {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type, options } = this.field;
if (type !== FieldType.SingleSelect) {
return super.asc(builderClient);
}
- const { choices } = options as SingleSelectOptionsDto;
+ const { choices } = options as ISelectFieldOptions;
+
+ if (!choices.length) return builderClient;
const optionSets = choices.map(({ name }) => name);
builderClient.orderByRaw(
- `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ??) ASC NULLS FIRST`,
- [...optionSets, this.columnName]
+ `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) ASC NULLS FIRST`,
+ [...optionSets]
);
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type, options } = this.field;
if (type !== FieldType.SingleSelect) {
return super.desc(builderClient);
}
- const { choices } = options as SingleSelectOptionsDto;
+ const { choices } = options as ISelectFieldOptions;
+
+ if (!choices.length) return builderClient;
const optionSets = choices.map(({ name }) => name);
builderClient.orderByRaw(
- `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ??) DESC NULLS LAST`,
- [...optionSets, this.columnName]
+ `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) DESC NULLS LAST`,
+ [...optionSets]
);
return builderClient;
}
+
+ getAscSQL() {
+ const { type, options } = this.field;
+
+ if (type !== FieldType.SingleSelect) {
+ return super.getAscSQL();
+ }
+ if (!this.columnName) {
+ return undefined;
+ }
+
+ const { choices } = options as ISelectFieldOptions;
+
+ const optionSets = choices.map(({ name }) => name);
+ return this.knex
+ .raw(
+ `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) ASC NULLS FIRST`,
+ [...optionSets]
+ )
+ .toQuery();
+ }
+
+ getDescSQL() {
+ const { type, options } = this.field;
+
+ if (type !== FieldType.SingleSelect) {
+ return super.getDescSQL();
+ }
+ if (!this.columnName) {
+ return undefined;
+ }
+
+ const { choices } = options as ISelectFieldOptions;
+
+ const optionSets = choices.map(({ name }) => name);
+ return this.knex
+ .raw(
+ `ARRAY_POSITION(ARRAY[${this.createSqlPlaceholders(optionSets)}], ${this.columnName}) DESC NULLS LAST`,
+
+ [...optionSets]
+ )
+ .toQuery();
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.function.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.function.ts
index 6ae02c707b..395112fa93 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.function.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.function.ts
@@ -4,22 +4,52 @@ import { AbstractSortFunction } from '../function/sort-function.abstract';
export class SortFunctionPostgres extends AbstractSortFunction {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { dbFieldType } = this.field;
builderClient.orderByRaw(
- `${dbFieldType === DbFieldType.Json ? '??::text' : '??'} ASC NULLS FIRST`,
- [this.columnName]
+ `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} ASC NULLS FIRST`
);
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { dbFieldType } = this.field;
builderClient.orderByRaw(
- `${dbFieldType === DbFieldType.Json ? '??::text' : '??'} DESC NULLS LAST`,
- [this.columnName]
+ `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} DESC NULLS LAST`
);
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { dbFieldType } = this.field;
+
+ return this.knex
+ .raw(
+ `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} ASC NULLS FIRST`
+ )
+ .toQuery();
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { dbFieldType } = this.field;
+
+ return this.knex
+ .raw(
+ `${dbFieldType === DbFieldType.Json ? `${this.columnName}::text` : this.columnName} DESC NULLS LAST`
+ )
+ .toQuery();
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.postgres.ts b/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.postgres.ts
index 4cee89f408..0d416f9110 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.postgres.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/postgres/sort-query.postgres.ts
@@ -1,46 +1,48 @@
-import type { IFieldInstance } from '../../../features/field/model/factory';
+import type { FieldCore } from '@teable/core';
+import type { IRecordQuerySortContext } from '../../../features/record/query-builder/record-query-builder.interface';
import { AbstractSortQuery } from '../sort-query.abstract';
import { MultipleDateTimeSortAdapter } from './multiple-value/multiple-datetime-sort.adapter';
import { MultipleJsonSortAdapter } from './multiple-value/multiple-json-sort.adapter';
import { MultipleNumberSortAdapter } from './multiple-value/multiple-number-sort.adapter';
+import { DateSortAdapter } from './single-value/date-sort.adapter';
import { JsonSortAdapter } from './single-value/json-sort.adapter';
import { StringSortAdapter } from './single-value/string-sort.adapter';
import { SortFunctionPostgres } from './sort-query.function';
export class SortQueryPostgres extends AbstractSortQuery {
- booleanSort(field: IFieldInstance): SortFunctionPostgres {
- return new SortFunctionPostgres(this.knex, field);
+ booleanSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {
+ return new SortFunctionPostgres(this.knex, field, context);
}
- numberSort(field: IFieldInstance): SortFunctionPostgres {
+ numberSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleNumberSortAdapter(this.knex, field);
+ return new MultipleNumberSortAdapter(this.knex, field, context);
}
- return new SortFunctionPostgres(this.knex, field);
+ return new SortFunctionPostgres(this.knex, field, context);
}
- dateTimeSort(field: IFieldInstance): SortFunctionPostgres {
+ dateTimeSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleDateTimeSortAdapter(this.knex, field);
+ return new MultipleDateTimeSortAdapter(this.knex, field, context);
}
- return new SortFunctionPostgres(this.knex, field);
+ return new DateSortAdapter(this.knex, field, context);
}
- stringSort(field: IFieldInstance): SortFunctionPostgres {
+ stringSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new SortFunctionPostgres(this.knex, field);
+ return new SortFunctionPostgres(this.knex, field, context);
}
- return new StringSortAdapter(this.knex, field);
+ return new StringSortAdapter(this.knex, field, context);
}
- jsonSort(field: IFieldInstance): SortFunctionPostgres {
+ jsonSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionPostgres {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleJsonSortAdapter(this.knex, field);
+ return new MultipleJsonSortAdapter(this.knex, field, context);
}
- return new JsonSortAdapter(this.knex, field);
+ return new JsonSortAdapter(this.knex, field, context);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sort-query.abstract.ts b/apps/nestjs-backend/src/db-provider/sort-query/sort-query.abstract.ts
index 59a2b239cf..b6b5068281 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sort-query.abstract.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sort-query.abstract.ts
@@ -1,8 +1,8 @@
import { Logger } from '@nestjs/common';
-import type { ISortItem } from '@teable/core';
+import type { FieldCore, ISortItem } from '@teable/core';
import { CellValueType, DbFieldType } from '@teable/core';
import type { Knex } from 'knex';
-import type { IFieldInstance } from '../../features/field/model/factory';
+import type { IRecordQuerySortContext } from '../../features/record/query-builder/record-query-builder.interface';
import type { ISortQueryExtra } from '../db.provider.interface';
import type { AbstractSortFunction } from './function/sort-function.abstract';
import type { ISortQueryInterface } from './sort-query.interface';
@@ -13,15 +13,43 @@ export abstract class AbstractSortQuery implements ISortQueryInterface {
constructor(
protected readonly knex: Knex,
protected readonly originQueryBuilder: Knex.QueryBuilder,
- protected readonly fields?: { [fieldId: string]: IFieldInstance },
+ protected readonly fields?: { [fieldId: string]: FieldCore },
protected readonly sortObjs?: ISortItem[],
- protected readonly extra?: ISortQueryExtra
+ protected readonly extra?: ISortQueryExtra,
+ protected readonly context?: IRecordQuerySortContext
) {}
appendSortBuilder(): Knex.QueryBuilder {
return this.parseSorts(this.originQueryBuilder, this.sortObjs);
}
+ getRawSortSQLText(): string {
+ return this.genSortSQL(this.sortObjs);
+ }
+
+ private genSortSQL(sortObjs?: ISortItem[]) {
+ const defaultSortSql = this.knex.raw(`?? ASC`, ['__auto_number']).toQuery();
+ if (!sortObjs?.length) {
+ return defaultSortSql;
+ }
+ const sortClauses = sortObjs
+ .map(({ fieldId, order }) => {
+ const field = this.fields && this.fields[fieldId];
+ if (!field) {
+ return undefined;
+ }
+ return this.getSortAdapter(field).generateSQL(order);
+ })
+ .filter((clause): clause is string => typeof clause === 'string' && clause.length > 0);
+
+ if (!sortClauses.length) {
+ return defaultSortSql;
+ }
+
+ sortClauses.push(defaultSortSql);
+ return sortClauses.join(', ');
+ }
+
private parseSorts(queryBuilder: Knex.QueryBuilder, sortObjs?: ISortItem[]): Knex.QueryBuilder {
if (!sortObjs || !sortObjs.length) {
return queryBuilder;
@@ -39,31 +67,31 @@ export abstract class AbstractSortQuery implements ISortQueryInterface {
return queryBuilder;
}
- private getSortAdapter(field: IFieldInstance): AbstractSortFunction {
+ private getSortAdapter(field: FieldCore): AbstractSortFunction {
const { dbFieldType } = field;
switch (field.cellValueType) {
case CellValueType.Boolean:
- return this.booleanSort(field);
+ return this.booleanSort(field, this.context);
case CellValueType.Number:
- return this.numberSort(field);
+ return this.numberSort(field, this.context);
case CellValueType.DateTime:
- return this.dateTimeSort(field);
+ return this.dateTimeSort(field, this.context);
case CellValueType.String: {
if (dbFieldType === DbFieldType.Json) {
- return this.jsonSort(field);
+ return this.jsonSort(field, this.context);
}
- return this.stringSort(field);
+ return this.stringSort(field, this.context);
}
}
}
- abstract booleanSort(field: IFieldInstance): AbstractSortFunction;
+ abstract booleanSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;
- abstract numberSort(field: IFieldInstance): AbstractSortFunction;
+ abstract numberSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;
- abstract dateTimeSort(field: IFieldInstance): AbstractSortFunction;
+ abstract dateTimeSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;
- abstract stringSort(field: IFieldInstance): AbstractSortFunction;
+ abstract stringSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;
- abstract jsonSort(field: IFieldInstance): AbstractSortFunction;
+ abstract jsonSort(field: FieldCore, context?: IRecordQuerySortContext): AbstractSortFunction;
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sort-query.interface.ts b/apps/nestjs-backend/src/db-provider/sort-query/sort-query.interface.ts
index 2dfe662b2c..413fd8e324 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sort-query.interface.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sort-query.interface.ts
@@ -2,4 +2,5 @@ import type { Knex } from 'knex';
export interface ISortQueryInterface {
appendSortBuilder(): Knex.QueryBuilder;
+ getRawSortSQLText(): string;
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-datetime-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-datetime-sort.adapter.ts
index 83230cbfc8..a47a3cb01c 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-datetime-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-datetime-sort.adapter.ts
@@ -1,14 +1,141 @@
+import { TimeFormatting, type DateFormattingPreset, type IDateFieldOptions } from '@teable/core';
import type { Knex } from 'knex';
+import { getSqliteDateTimeFormatString } from '../../../group-query/format-string';
+import { getOffset } from '../../../search-query/get-offset';
import { SortFunctionSqlite } from '../sort-query.function';
export class MultipleDateTimeSortAdapter extends SortFunctionSqlite {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`json_extract(??, '$[0]') ASC NULLS FIRST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ const orderByColumn =
+ time === TimeFormatting.None
+ ? this.knex.raw(
+ `
+ (
+ SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) ASC NULLS FIRST
+ `,
+ [formatString, offsetString]
+ )
+ : this.knex.raw(
+ `
+ (
+ SELECT group_concat(elem.value, ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) ASC NULLS FIRST
+ `
+ );
+ builderClient.orderByRaw(orderByColumn);
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`json_extract(??, '$[0]') DESC NULLS LAST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ const orderByColumn =
+ time === TimeFormatting.None
+ ? this.knex.raw(
+ `
+ (
+ SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) DESC NULLS LAST
+ `,
+ [formatString, offsetString]
+ )
+ : this.knex.raw(
+ `
+ (
+ SELECT group_concat(elem.value, ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) DESC NULLS LAST
+ `
+ );
+ builderClient.orderByRaw(orderByColumn);
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(
+ `
+ (
+ SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) ASC NULLS FIRST
+ `,
+ [formatString, offsetString]
+ )
+ .toQuery();
+ } else {
+ return this.knex
+ .raw(
+ `
+ (
+ SELECT group_concat(elem.value, ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) ASC NULLS FIRST
+ `
+ )
+ .toQuery();
+ }
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(
+ `
+ (
+ SELECT group_concat(strftime(?, DATETIME(elem.value, ?)), ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) DESC NULLS LAST
+ `,
+ [formatString, offsetString]
+ )
+ .toQuery();
+ } else {
+ return this.knex
+ .raw(
+ `
+ (
+ SELECT group_concat(elem.value, ', ')
+ FROM json_each(${this.columnName}) as elem
+ ) DESC NULLS LAST
+ `
+ )
+ .toQuery();
+ }
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-json-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-json-sort.adapter.ts
index 595c00cd70..330c67f683 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-json-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-json-sort.adapter.ts
@@ -3,12 +3,56 @@ import { SortFunctionSqlite } from '../sort-query.function';
export class MultipleJsonSortAdapter extends SortFunctionSqlite {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`json_extract(??, '$[0]') ASC NULLS FIRST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ builderClient.orderByRaw(
+ `
+ json_extract(${this.columnName}, '$[0]') ASC NULLS FIRST,
+ json_array_length${this.columnName} ASC NULLS FIRST
+ `
+ );
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`json_extract(??, '$[0]') DESC NULLS LAST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ builderClient.orderByRaw(
+ `
+ json_extract(${this.columnName}, '$[0]') DESC NULLS LAST,
+ json_array_length(${this.columnName}) DESC NULLS LAST
+ `
+ );
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ return this.knex
+ .raw(
+ `
+ json_extract(${this.columnName}, '$[0]') ASC NULLS FIRST,
+ json_array_length(${this.columnName}) ASC NULLS FIRST
+ `
+ )
+ .toQuery();
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ return this.knex
+ .raw(
+ `
+ json_extract(${this.columnName}, '$[0]') DESC NULLS LAST,
+ json_array_length(${this.columnName}) DESC NULLS LAST
+ `
+ )
+ .toQuery();
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-number-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-number-sort.adapter.ts
index c245520e4c..e610a08e43 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-number-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/multiple-value/multiple-number-sort.adapter.ts
@@ -1,14 +1,74 @@
+import type { INumberFieldOptions } from '@teable/core';
import type { Knex } from 'knex';
import { SortFunctionSqlite } from '../sort-query.function';
export class MultipleNumberSortAdapter extends SortFunctionSqlite {
+ private buildRoundedFirstElementExpr(precision: number) {
+ return this.knex.raw(
+ `
+ ROUND(CAST(json_extract(${this.columnName}, '$[0]') AS REAL), ?)
+ `,
+ [precision]
+ );
+ }
+
+ private buildRoundedArrayExpr(precision: number) {
+ return this.knex.raw(
+ `
+ (
+ SELECT json_group_array(ROUND(CAST(elem.value AS REAL), ?))
+ FROM json_each(${this.columnName}) as elem
+ )
+ `,
+ [precision]
+ );
+ }
+
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`json_extract(??, '$[0]') ASC NULLS FIRST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ builderClient.orderByRaw(`${firstElementExpr} ASC NULLS FIRST`);
+ builderClient.orderByRaw(`${arrayExpr} ASC NULLS FIRST`);
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
- builderClient.orderByRaw(`json_extract(??, '$[0]') DESC NULLS LAST`, [this.columnName]);
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ builderClient.orderByRaw(`${firstElementExpr} DESC NULLS LAST`);
+ builderClient.orderByRaw(`${arrayExpr} DESC NULLS LAST`);
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ return `${firstElementExpr} ASC NULLS FIRST, ${arrayExpr} ASC NULLS FIRST`;
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { precision } = (options as INumberFieldOptions).formatting;
+ const firstElementExpr = this.buildRoundedFirstElementExpr(precision).toQuery();
+ const arrayExpr = this.buildRoundedArrayExpr(precision).toQuery();
+ return `${firstElementExpr} DESC NULLS LAST, ${arrayExpr} DESC NULLS LAST`;
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/date-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/date-sort.adapter.ts
new file mode 100644
index 0000000000..a1cb6c8f97
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/date-sort.adapter.ts
@@ -0,0 +1,91 @@
+import { type IDateFieldOptions, type DateFormattingPreset, TimeFormatting } from '@teable/core';
+import type { Knex } from 'knex';
+import { getSqliteDateTimeFormatString } from '../../../group-query/format-string';
+import { getOffset } from '../../../search-query/get-offset';
+import { SortFunctionSqlite } from '../sort-query.function';
+
+export class DateSortAdapter extends SortFunctionSqlite {
+ asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ if (time === TimeFormatting.None) {
+ builderClient.orderByRaw('strftime(?, DATETIME(${this.columnName}, ?)) ASC NULLS FIRST', [
+ formatString,
+ offsetString,
+ ]);
+ } else {
+ builderClient.orderByRaw('${this.columnName} ASC NULLS FIRST');
+ }
+
+ return builderClient;
+ }
+
+ desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ if (time === TimeFormatting.None) {
+ builderClient.orderByRaw(`strftime(?, DATETIME(${this.columnName}, ?)) DESC NULLS LAST`, [
+ formatString,
+ offsetString,
+ ]);
+ } else {
+ builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);
+ }
+
+ return builderClient;
+ }
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(`strftime(?, DATETIME(${this.columnName}, ?)) ASC NULLS FIRST`, [
+ formatString,
+ offsetString,
+ ])
+ .toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();
+ }
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { options } = this.field;
+ const { date, time, timeZone } = (options as IDateFieldOptions).formatting;
+ const formatString = getSqliteDateTimeFormatString(date as DateFormattingPreset, time);
+ const offsetString = `${getOffset(timeZone)} hour`;
+
+ if (time === TimeFormatting.None) {
+ return this.knex
+ .raw(`strftime(?, DATETIME(${this.columnName}, ?)) DESC NULLS LAST`, [
+ formatString,
+ offsetString,
+ ])
+ .toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();
+ }
+ }
+}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/json-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/json-sort.adapter.ts
index bd0b9f9362..1d79999895 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/json-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/json-sort.adapter.ts
@@ -1,27 +1,59 @@
-import { FieldType } from '@teable/core';
import type { Knex } from 'knex';
+import { isUserOrLink } from '../../../../utils/is-user-or-link';
import { SortFunctionSqlite } from '../sort-query.function';
export class JsonSortAdapter extends SortFunctionSqlite {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
- builderClient.orderByRaw(`json_extract(??, '$.title') ASC NULLS FIRST`, [this.columnName]);
+ if (isUserOrLink(type)) {
+ builderClient.orderByRaw(`json_extract(${this.columnName}, '$.title') ASC NULLS FIRST`);
} else {
- builderClient.orderByRaw(`?? ASC NULLS FIRST`, [this.columnName]);
+ builderClient.orderByRaw(`${this.columnName} ASC NULLS FIRST`);
}
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type } = this.field;
- if (type === FieldType.Link || type === FieldType.User) {
- builderClient.orderByRaw(`json_extract(??, '$.title') DESC NULLS LAST`, [this.columnName]);
+ if (isUserOrLink(type)) {
+ builderClient.orderByRaw(`json_extract(${this.columnName}, '$.title') DESC NULLS LAST`);
} else {
- builderClient.orderByRaw(`?? DESC NULLS LAST`, [this.columnName]);
+ builderClient.orderByRaw(`${this.columnName} DESC NULLS LAST`);
}
return builderClient;
}
+
+ getAscSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { type } = this.field;
+
+ if (isUserOrLink(type)) {
+ return this.knex.raw(`json_extract(${this.columnName}, '$.title') ASC NULLS FIRST`).toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName} ASC NULLS FIRST`).toQuery();
+ }
+ }
+
+ getDescSQL() {
+ if (!this.columnName) {
+ return undefined;
+ }
+ const { type } = this.field;
+
+ if (isUserOrLink(type)) {
+ return this.knex.raw(`json_extract(${this.columnName}, '$.title') DESC NULLS LAST`).toQuery();
+ } else {
+ return this.knex.raw(`${this.columnName} DESC NULLS LAST`).toQuery();
+ }
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/string-sort.adapter.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/string-sort.adapter.ts
index 874fb5795e..54480be888 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/string-sort.adapter.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/single-value/string-sort.adapter.ts
@@ -1,38 +1,80 @@
+import type { ISelectFieldOptions } from '@teable/core';
import { FieldType } from '@teable/core';
import type { Knex } from 'knex';
-import type { SingleSelectOptionsDto } from '../../../../features/field/model/field-dto/single-select-field.dto';
import { SortFunctionSqlite } from '../sort-query.function';
export class StringSortAdapter extends SortFunctionSqlite {
asc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type, options } = this.field;
if (type !== FieldType.SingleSelect) {
return super.asc(builderClient);
}
- const { choices } = options as SingleSelectOptionsDto;
+ const { choices } = options as ISelectFieldOptions;
const optionSets = choices.map(({ name }) => name);
- builderClient.orderByRaw(`${this.generateOrderByCase(optionSets)} ASC NULLS FIRST`, [
- this.columnName,
- ]);
+ builderClient.orderByRaw(
+ `${this.generateOrderByCase(optionSets, this.columnName)} ASC NULLS FIRST`
+ );
return builderClient;
}
desc(builderClient: Knex.QueryBuilder): Knex.QueryBuilder {
+ if (!this.columnName) {
+ return builderClient;
+ }
const { type, options } = this.field;
if (type !== FieldType.SingleSelect) {
return super.desc(builderClient);
}
- const { choices } = options as SingleSelectOptionsDto;
+ const { choices } = options as ISelectFieldOptions;
const optionSets = choices.map(({ name }) => name);
- builderClient.orderByRaw(`${this.generateOrderByCase(optionSets)} DESC NULLS LAST`, [
- this.columnName,
- ]);
+ builderClient.orderByRaw(
+ `${this.generateOrderByCase(optionSets, this.columnName)} DESC NULLS LAST`
+ );
return builderClient;
}
+
+ getAscSQL() {
+ const { type, options } = this.field;
+
+ if (type !== FieldType.SingleSelect) {
+ return super.getAscSQL();
+ }
+ if (!this.columnName) {
+ return undefined;
+ }
+
+ const { choices } = options as ISelectFieldOptions;
+
+ const optionSets = choices.map(({ name }) => name);
+ return this.knex
+ .raw(`${this.generateOrderByCase(optionSets, this.columnName)} ASC NULLS FIRST`)
+ .toQuery();
+ }
+
+ getDescSQL() {
+ const { type, options } = this.field;
+
+ if (type !== FieldType.SingleSelect) {
+ return super.getDescSQL();
+ }
+ if (!this.columnName) {
+ return undefined;
+ }
+
+ const { choices } = options as ISelectFieldOptions;
+
+ const optionSets = choices.map(({ name }) => name);
+ return this.knex
+ .raw(`${this.generateOrderByCase(optionSets, this.columnName)} DESC NULLS LAST`)
+ .toQuery();
+ }
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.function.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.function.ts
index 4a01dbc882..b51558c3ef 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.function.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.function.ts
@@ -1,8 +1,8 @@
import { AbstractSortFunction } from '../function/sort-function.abstract';
export class SortFunctionSqlite extends AbstractSortFunction {
- generateOrderByCase(keys: string[]): string {
+ generateOrderByCase(keys: string[], columnName: string): string {
const cases = keys.map((key, index) => `WHEN '${key}' THEN ${index + 1}`).join(' ');
- return `CASE ?? ${cases} ELSE -1 END`;
+ return `CASE ${columnName} ${cases} ELSE -1 END`;
}
}
diff --git a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.sqlite.ts b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.sqlite.ts
index b840b54c63..1fba30c23e 100644
--- a/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.sqlite.ts
+++ b/apps/nestjs-backend/src/db-provider/sort-query/sqlite/sort-query.sqlite.ts
@@ -1,44 +1,47 @@
-import type { IFieldInstance } from '../../../features/field/model/factory';
+import type { FieldCore } from '@teable/core';
+import type { IRecordQuerySortContext } from '../../../features/record/query-builder/record-query-builder.interface';
import { AbstractSortQuery } from '../sort-query.abstract';
import { MultipleDateTimeSortAdapter } from './multiple-value/multiple-datetime-sort.adapter';
import { MultipleJsonSortAdapter } from './multiple-value/multiple-json-sort.adapter';
import { MultipleNumberSortAdapter } from './multiple-value/multiple-number-sort.adapter';
+import { DateSortAdapter } from './single-value/date-sort.adapter';
import { JsonSortAdapter } from './single-value/json-sort.adapter';
import { StringSortAdapter } from './single-value/string-sort.adapter';
import { SortFunctionSqlite } from './sort-query.function';
export class SortQuerySqlite extends AbstractSortQuery {
- booleanSort(field: IFieldInstance): SortFunctionSqlite {
- return new SortFunctionSqlite(this.knex, field);
+ booleanSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {
+ return new SortFunctionSqlite(this.knex, field, context);
}
- numberSort(field: IFieldInstance): SortFunctionSqlite {
+ numberSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleNumberSortAdapter(this.knex, field);
+ return new MultipleNumberSortAdapter(this.knex, field, context);
}
- return new SortFunctionSqlite(this.knex, field);
+ return new SortFunctionSqlite(this.knex, field, context);
}
- dateTimeSort(field: IFieldInstance): SortFunctionSqlite {
+
+ dateTimeSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleDateTimeSortAdapter(this.knex, field);
+ return new MultipleDateTimeSortAdapter(this.knex, field, context);
}
- return new SortFunctionSqlite(this.knex, field);
+ return new DateSortAdapter(this.knex, field, context);
}
- stringSort(field: IFieldInstance): SortFunctionSqlite {
+ stringSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new SortFunctionSqlite(this.knex, field);
+ return new SortFunctionSqlite(this.knex, field, context);
}
- return new StringSortAdapter(this.knex, field);
+ return new StringSortAdapter(this.knex, field, context);
}
- jsonSort(field: IFieldInstance): SortFunctionSqlite {
+ jsonSort(field: FieldCore, context?: IRecordQuerySortContext): SortFunctionSqlite {
const { isMultipleCellValue } = field;
if (isMultipleCellValue) {
- return new MultipleJsonSortAdapter(this.knex, field);
+ return new MultipleJsonSortAdapter(this.knex, field, context);
}
- return new JsonSortAdapter(this.knex, field);
+ return new JsonSortAdapter(this.knex, field, context);
}
}
diff --git a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts
index bfbfa4c000..4532ec63a6 100644
--- a/apps/nestjs-backend/src/db-provider/sqlite.provider.ts
+++ b/apps/nestjs-backend/src/db-provider/sqlite.provider.ts
@@ -1,20 +1,66 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Logger } from '@nestjs/common';
-import type { IAggregationField, IFilter, ISortItem } from '@teable/core';
-import { DriverClient } from '@teable/core';
+import type {
+ IFilter,
+ ILookupLinkOptionsVo,
+ ISortItem,
+ FieldCore,
+ TableDomain,
+} from '@teable/core';
+import { DriverClient, parseFormulaToSQL, FieldType } from '@teable/core';
+import type { PrismaClient } from '@teable/db-main-prisma';
+import type { IAggregationField, ISearchIndexByQueryRo, TableIndex } from '@teable/openapi';
import type { Knex } from 'knex';
import type { IFieldInstance } from '../features/field/model/factory';
-import type { SchemaType } from '../features/field/util';
+import type {
+ IRecordQueryFilterContext,
+ IRecordQuerySortContext,
+ IRecordQueryGroupContext,
+ IRecordQueryAggregateContext,
+} from '../features/record/query-builder/record-query-builder.interface';
+import type {
+ IGeneratedColumnQueryInterface,
+ IFormulaConversionContext,
+ IFormulaConversionResult,
+ ISelectQueryInterface,
+ ISelectFormulaConversionContext,
+} from '../features/record/query-builder/sql-conversion.visitor';
+import {
+ GeneratedColumnSqlConversionVisitor,
+ SelectColumnSqlConversionVisitor,
+} from '../features/record/query-builder/sql-conversion.visitor';
import type { IAggregationQueryInterface } from './aggregation-query/aggregation-query.interface';
import { AggregationQuerySqlite } from './aggregation-query/sqlite/aggregation-query.sqlite';
+import type { BaseQueryAbstract } from './base-query/abstract';
+import { BaseQuerySqlite } from './base-query/base-query.sqlite';
+import type { ICreateDatabaseColumnContext } from './create-database-column-query/create-database-column-field-visitor.interface';
+import { CreateSqliteDatabaseColumnFieldVisitor } from './create-database-column-query/create-database-column-field-visitor.sqlite';
import type {
IAggregationQueryExtra,
+ ICalendarDailyCollectionQueryProps,
IDbProvider,
IFilterQueryExtra,
ISortQueryExtra,
} from './db.provider.interface';
+import type {
+ IDropDatabaseColumnContext,
+ DropColumnOperationType,
+} from './drop-database-column-query/drop-database-column-field-visitor.interface';
+import { DropSqliteDatabaseColumnFieldVisitor } from './drop-database-column-query/drop-database-column-field-visitor.sqlite';
+import { DuplicateAttachmentTableQuerySqlite } from './duplicate-table/duplicate-attachment-table-query.sqlite';
+import { DuplicateTableQuerySqlite } from './duplicate-table/duplicate-query.sqlite';
import type { IFilterQueryInterface } from './filter-query/filter-query.interface';
import { FilterQuerySqlite } from './filter-query/sqlite/filter-query.sqlite';
+import { GeneratedColumnQuerySqlite } from './generated-column-query/sqlite/generated-column-query.sqlite';
+import type { IGroupQueryExtra, IGroupQueryInterface } from './group-query/group-query.interface';
+import { GroupQuerySqlite } from './group-query/group-query.sqlite';
+import type { IntegrityQueryAbstract } from './integrity-query/abstract';
+import { IntegrityQuerySqlite } from './integrity-query/integrity-query.sqlite';
+import { SearchQueryAbstract } from './search-query/abstract';
+import { getOffset } from './search-query/get-offset';
+import { IndexBuilderSqlite } from './search-query/search-index-builder.sqlite';
+import { SearchQuerySqliteBuilder, SearchQuerySqlite } from './search-query/search-query.sqlite';
+import { SelectQuerySqlite } from './select-query/sqlite/select-query.sqlite';
import type { ISortQueryInterface } from './sort-query/sort-query.interface';
import { SortQuerySqlite } from './sort-query/sqlite/sort-query.sqlite';
@@ -29,19 +75,54 @@ export class SqliteProvider implements IDbProvider {
return undefined;
}
+ dropSchema(_schemaName: string) {
+ return undefined;
+ }
+
generateDbTableName(baseId: string, name: string) {
return `${baseId}_${name}`;
}
+ // make no-sense
+ getForeignKeysInfo(_tableName: string): string {
+ return this.knex
+ .raw(
+ 'SELECT NULL as constraint_name, NULL as column_name, NULL as referenced_column_name, NULL as referenced_table_schema, NULL as referenced_table_name WHERE 1=0'
+ )
+ .toQuery();
+ }
+
renameTableName(oldTableName: string, newTableName: string) {
return [this.knex.raw('ALTER TABLE ?? RENAME TO ??', [oldTableName, newTableName]).toQuery()];
}
dropTable(tableName: string): string {
- return this.knex.raw('DROP TABLE ??', [tableName]).toQuery();
+ return this.knex.raw('DROP TABLE IF EXISTS ??', [tableName]).toQuery();
}
- renameColumnName(tableName: string, oldName: string, newName: string): string[] {
+ async checkColumnExist(
+ tableName: string,
+ columnName: string,
+ prisma: PrismaClient
+ ): Promise {
+ const sql = this.columnInfo(tableName);
+ const columns = await prisma.$queryRawUnsafe<{ name: string }[]>(sql);
+ return columns.some((column) => column.name === columnName);
+ }
+
+ checkTableExist(tableName: string): string {
+ return this.knex
+ .raw(
+ `SELECT EXISTS (
+ SELECT 1 FROM sqlite_master
+ WHERE type='table' AND name = ?
+ ) as "exists"`,
+ [tableName]
+ )
+ .toQuery();
+ }
+
+ renameColumn(tableName: string, oldName: string, newName: string): string[] {
return [
this.knex
.raw('ALTER TABLE ?? RENAME COLUMN ?? TO ??', [tableName, oldName, newName])
@@ -49,13 +130,88 @@ export class SqliteProvider implements IDbProvider {
];
}
- modifyColumnSchema(tableName: string, columnName: string, schemaType: SchemaType): string[] {
- return [
- this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [tableName, columnName]).toQuery(),
- this.knex
- .raw(`ALTER TABLE ?? ADD COLUMN ?? ??`, [tableName, columnName, schemaType])
- .toQuery(),
- ];
+ modifyColumnSchema(
+ tableName: string,
+ oldFieldInstance: IFieldInstance,
+ fieldInstance: IFieldInstance,
+ tableDomain: TableDomain,
+ linkContext?: { tableId: string; tableNameMap: Map }
+ ): string[] {
+ const queries: string[] = [];
+
+ // First, drop ALL columns associated with the field (including generated columns)
+ queries.push(...this.dropColumn(tableName, oldFieldInstance, linkContext));
+
+ // For Link fields, delegate creation to link service to avoid double creation
+ if (fieldInstance.type === FieldType.Link && !fieldInstance.isLookup) {
+ return queries;
+ }
+
+ const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {
+ const createContext: ICreateDatabaseColumnContext = {
+ table,
+ field: fieldInstance,
+ fieldId: fieldInstance.id,
+ dbFieldName: fieldInstance.dbFieldName,
+ unique: fieldInstance.unique,
+ notNull: fieldInstance.notNull,
+ dbProvider: this,
+ tableDomain,
+ tableId: linkContext?.tableId || '',
+ tableName,
+ knex: this.knex,
+ tableNameMap: linkContext?.tableNameMap || new Map(),
+ };
+
+ // Use visitor pattern to recreate columns
+ const visitor = new CreateSqliteDatabaseColumnFieldVisitor(createContext);
+ fieldInstance.accept(visitor);
+ });
+
+ const alterTableQueries = alterTableBuilder.toSQL().map((item) => item.sql);
+ queries.push(...alterTableQueries);
+
+ return queries;
+ }
+
+ createColumnSchema(
+ tableName: string,
+ fieldInstance: IFieldInstance,
+ tableDomain: TableDomain,
+ isNewTable: boolean,
+ tableId: string,
+ tableNameMap: Map,
+ isSymmetricField?: boolean,
+ skipBaseColumnCreation?: boolean
+ ): string[] {
+ let visitor: CreateSqliteDatabaseColumnFieldVisitor | undefined = undefined;
+ const alterTableBuilder = this.knex.schema.alterTable(tableName, (table) => {
+ const context: ICreateDatabaseColumnContext = {
+ table,
+ field: fieldInstance,
+ fieldId: fieldInstance.id,
+ dbFieldName: fieldInstance.dbFieldName,
+ unique: fieldInstance.unique,
+ notNull: fieldInstance.notNull,
+ dbProvider: this,
+ tableDomain,
+ isNewTable,
+ tableId,
+ tableName,
+ knex: this.knex,
+ tableNameMap,
+ isSymmetricField,
+ skipBaseColumnCreation,
+ };
+ visitor = new CreateSqliteDatabaseColumnFieldVisitor(context);
+ fieldInstance.accept(visitor);
+ });
+
+ const mainSqls = alterTableBuilder.toSQL().map((item) => item.sql);
+ const additionalSqls =
+ (visitor as CreateSqliteDatabaseColumnFieldVisitor | undefined)?.getSql() ?? [];
+
+ return [...mainSqls, ...additionalSqls];
}
splitTableName(tableName: string): string[] {
@@ -66,8 +222,22 @@ export class SqliteProvider implements IDbProvider {
return `${schemaName}_${dbTableName}`;
}
- dropColumn(tableName: string, columnName: string): string[] {
- return [this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [tableName, columnName]).toQuery()];
+ dropColumn(
+ tableName: string,
+ fieldInstance: IFieldInstance,
+ linkContext?: { tableId: string; tableNameMap: Map },
+ operationType?: DropColumnOperationType
+ ): string[] {
+ const context: IDropDatabaseColumnContext = {
+ tableName,
+ knex: this.knex,
+ linkContext,
+ operationType,
+ };
+
+ // Use visitor pattern to drop columns
+ const visitor = new DropSqliteDatabaseColumnFieldVisitor(context);
+ return fieldInstance.accept(visitor);
}
dropColumnAndIndex(tableName: string, columnName: string, indexName: string): string[] {
@@ -81,6 +251,58 @@ export class SqliteProvider implements IDbProvider {
return this.knex.raw(`PRAGMA table_info(??)`, [tableName]).toQuery();
}
+ updateJsonColumn(
+ tableName: string,
+ columnName: string,
+ id: string,
+ key: string,
+ value: string
+ ): string {
+ return this.knex(tableName)
+ .where(this.knex.raw(`json_extract(${columnName}, '$.id') = ?`, [id]))
+ .update({
+ [columnName]: this.knex.raw(
+ `
+ json_patch(${columnName}, json_object(?, ?))
+ `,
+ [key, value]
+ ),
+ })
+ .toQuery();
+ }
+
+ updateJsonArrayColumn(
+ tableName: string,
+ columnName: string,
+ id: string,
+ key: string,
+ value: string
+ ): string {
+ return this.knex(tableName)
+ .update({
+ [columnName]: this.knex.raw(
+ `
+ json(
+ (
+ SELECT json_group_array(
+ json(
+ CASE
+ WHEN json_extract(value, '$.id') = ?
+ THEN json_patch(value, json_object(?, ?))
+ ELSE value
+ END
+ )
+ )
+ FROM json_each(${columnName})
+ )
+ )
+ `,
+ [id, key, value]
+ ),
+ })
+ .toQuery();
+ }
+
duplicateTable(
fromSchema: string,
toSchema: string,
@@ -102,7 +324,7 @@ export class SqliteProvider implements IDbProvider {
}
batchInsertSql(tableName: string, insertData: ReadonlyArray): string {
- // TODO: The code doesn't taste good because knex utilizes the "select-stmt" mode to construct SQL queries for SQLite batchInsert.
+ // to-do: The code doesn't taste good because knex utilizes the "select-stmt" mode to construct SQL queries for SQLite batchInsert.
// This is a temporary solution, and I'm actively keeping an eye on this issue for further developments.
const builder = this.knex.client.queryBuilder();
builder.insert(insertData).into(tableName).toSQL();
@@ -146,38 +368,418 @@ export class SqliteProvider implements IDbProvider {
return { insertTempTableSql, updateRecordSql };
}
+ updateFromSelectSql(params: {
+ dbTableName: string;
+ idFieldName: string;
+ subQuery: Knex.QueryBuilder;
+ dbFieldNames: string[];
+ returningDbFieldNames?: string[];
+ restrictRecordIds?: string[];
+ }): string {
+ const {
+ dbTableName,
+ idFieldName,
+ subQuery,
+ dbFieldNames,
+ returningDbFieldNames,
+ restrictRecordIds,
+ } = params;
+ const subQuerySql = subQuery.toQuery();
+ const wrap = (id: string) => this.knex.client.wrapIdentifier(id);
+ const setClauses = dbFieldNames.map(
+ (c) =>
+ `${wrap(c)} = (SELECT s.${wrap(c)} FROM (${subQuerySql}) AS s WHERE s.${wrap(
+ idFieldName
+ )} = ${dbTableName}.${wrap(idFieldName)})`
+ );
+ const wrappedVersion = wrap('__version');
+ // Always bump __version so published ShareDB ops stay aligned with DB state
+ setClauses.push(`${wrappedVersion} = ${dbTableName}.${wrappedVersion} + 1`);
+ const setClause = setClauses.join(', ');
+ const returningColumns = [
+ wrap(idFieldName),
+ wrappedVersion,
+ `${dbTableName}.${wrappedVersion} - 1 as ${wrap('__prev_version')}`,
+ ...(returningDbFieldNames || dbFieldNames).map((c) => wrap(c)),
+ ];
+ const returning = returningColumns.join(', ');
+ const restrictClause =
+ restrictRecordIds && restrictRecordIds.length
+ ? ` AND ${dbTableName}.${wrap(idFieldName)} IN (${restrictRecordIds
+ .map((id) => `'${id.replace(/'/g, "''")}'`)
+ .join(', ')})`
+ : '';
+ return `UPDATE ${dbTableName} SET ${setClause} WHERE EXISTS (SELECT 1 FROM (${subQuerySql}) AS s WHERE s.${wrap(
+ idFieldName
+ )} = ${dbTableName}.${wrap(idFieldName)})${restrictClause} RETURNING ${returning}`;
+ }
+
aggregationQuery(
originQueryBuilder: Knex.QueryBuilder,
- dbTableName: string,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
aggregationFields?: IAggregationField[],
- extra?: IAggregationQueryExtra
+ extra?: IAggregationQueryExtra,
+ context?: IRecordQueryAggregateContext
): IAggregationQueryInterface {
return new AggregationQuerySqlite(
this.knex,
originQueryBuilder,
- dbTableName,
fields,
aggregationFields,
- extra
+ extra,
+ context
);
}
filterQuery(
originQueryBuilder: Knex.QueryBuilder,
- fields?: { [p: string]: IFieldInstance },
+ fields?: { [p: string]: FieldCore },
filter?: IFilter,
- extra?: IFilterQueryExtra
+ extra?: IFilterQueryExtra,
+ context?: IRecordQueryFilterContext
): IFilterQueryInterface {
- return new FilterQuerySqlite(originQueryBuilder, fields, filter, extra);
+ return new FilterQuerySqlite(originQueryBuilder, fields, filter, extra, this, context);
}
sortQuery(
originQueryBuilder: Knex.QueryBuilder,
- fields?: { [fieldId: string]: IFieldInstance },
+ fields?: { [fieldId: string]: FieldCore },
sortObjs?: ISortItem[],
- extra?: ISortQueryExtra
+ extra?: ISortQueryExtra,
+ context?: IRecordQuerySortContext
): ISortQueryInterface {
- return new SortQuerySqlite(this.knex, originQueryBuilder, fields, sortObjs, extra);
+ return new SortQuerySqlite(this.knex, originQueryBuilder, fields, sortObjs, extra, context);
+ }
+
+ groupQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ fieldMap?: { [fieldId: string]: IFieldInstance },
+ groupFieldIds?: string[],
+ extra?: IGroupQueryExtra,
+ context?: IRecordQueryGroupContext
+ ): IGroupQueryInterface {
+ return new GroupQuerySqlite(
+ this.knex,
+ originQueryBuilder,
+ fieldMap,
+ groupFieldIds,
+ extra,
+ context
+ );
+ }
+
+ searchQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ searchFields: IFieldInstance[],
+ tableIndex: TableIndex[],
+ search: [string, string?, boolean?],
+ context?: IRecordQueryFilterContext
+ ) {
+ return SearchQueryAbstract.appendQueryBuilder(
+ SearchQuerySqlite,
+ originQueryBuilder,
+ searchFields,
+ tableIndex,
+ search,
+ context
+ );
+ }
+
+ searchCountQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ searchField: IFieldInstance[],
+ search: [string, string?, boolean?],
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext
+ ) {
+ return SearchQueryAbstract.buildSearchCountQuery(
+ SearchQuerySqlite,
+ originQueryBuilder,
+ searchField,
+ search,
+ tableIndex,
+ context
+ );
+ }
+
+ searchIndexQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ dbTableName: string,
+ searchField: IFieldInstance[],
+ searchIndexRo: ISearchIndexByQueryRo,
+ tableIndex: TableIndex[],
+ context?: IRecordQueryFilterContext,
+ baseSortIndex?: string,
+ setFilterQuery?: (qb: Knex.QueryBuilder) => void,
+ setSortQuery?: (qb: Knex.QueryBuilder) => void
+ ) {
+ return new SearchQuerySqliteBuilder(
+ originQueryBuilder,
+ dbTableName,
+ searchField,
+ searchIndexRo,
+ tableIndex,
+ context,
+ baseSortIndex,
+ setFilterQuery,
+ setSortQuery
+ ).getSearchIndexQuery();
+ }
+
+ searchIndex() {
+ return new IndexBuilderSqlite();
+ }
+
+ duplicateTableQuery(queryBuilder: Knex.QueryBuilder) {
+ return new DuplicateTableQuerySqlite(queryBuilder);
+ }
+
+ duplicateAttachmentTableQuery(queryBuilder: Knex.QueryBuilder) {
+ return new DuplicateAttachmentTableQuerySqlite(queryBuilder);
+ }
+
+ shareFilterCollaboratorsQuery(
+ originQueryBuilder: Knex.QueryBuilder,
+ dbFieldName: string,
+ isMultipleCellValue?: boolean | null
+ ) {
+ if (isMultipleCellValue) {
+ originQueryBuilder
+ .distinct(this.knex.raw(`json_extract(json_each.value, '$.id') AS user_id`))
+ .crossJoin(this.knex.raw(`json_each(${dbFieldName})`));
+ } else {
+ originQueryBuilder.distinct(this.knex.raw(`json_extract(${dbFieldName}, '$.id') AS user_id`));
+ }
+ }
+
+ baseQuery(): BaseQueryAbstract {
+ return new BaseQuerySqlite(this.knex);
+ }
+
+ integrityQuery(): IntegrityQueryAbstract {
+ return new IntegrityQuerySqlite(this.knex);
+ }
+
+ calendarDailyCollectionQuery(
+ qb: Knex.QueryBuilder,
+ props: ICalendarDailyCollectionQueryProps
+ ): Knex.QueryBuilder {
+ const { startDate, endDate, startField, endField } = props;
+ const timezone = startField.options.formatting.timeZone;
+ const offsetStr = `${getOffset(timezone)} hour`;
+
+ const datesSubquery = this.knex.raw(
+ `WITH RECURSIVE dates(date) AS (
+ SELECT date(datetime(?, ?)) as date
+ UNION ALL
+ SELECT date(datetime(date, ?))
+ FROM dates
+ WHERE date < date(datetime(?, ?))
+ )
+ SELECT date FROM dates`,
+ [startDate, offsetStr, '+1 day', endDate, offsetStr]
+ );
+
+ return qb
+ .select([
+ this.knex.raw('d.date'),
+ this.knex.raw('COUNT(*) as count'),
+ this.knex.raw('GROUP_CONCAT(??) as ids', ['__id']),
+ ])
+ .crossJoin(datesSubquery.wrap('(', ') as d'))
+ .where((builder) => {
+ builder
+ .whereRaw(`date(datetime(??, ?)) <= date(datetime(?, ?))`, [
+ startField.dbFieldName,
+ offsetStr,
+ endDate,
+ offsetStr,
+ ])
+ .andWhere(
+ this.knex.raw(`date(datetime(COALESCE(??, ??), ?))`, [
+ endField.dbFieldName,
+ startField.dbFieldName,
+ offsetStr,
+ ]),
+ '>=',
+ this.knex.raw(`date(datetime(?, ?))`, [startDate, offsetStr])
+ );
+ })
+ .andWhere((builder) => {
+ builder.whereRaw(
+ `date(datetime(??, ?)) <= d.date AND date(datetime(COALESCE(??, ??), ?)) >= d.date`,
+ [
+ startField.dbFieldName,
+ offsetStr,
+ endField.dbFieldName,
+ startField.dbFieldName,
+ offsetStr,
+ ]
+ );
+ })
+ .groupBy('d.date')
+ .orderBy('d.date', 'asc');
+ }
+
+ // select id and lookup_options for "field" table options is a json saved in string format, match optionsKey and value
+ // please use json method in sqlite
+ lookupOptionsQuery(optionsKey: keyof ILookupLinkOptionsVo, value: string): string {
+ return this.knex('field')
+ .select({
+ tableId: 'table_id',
+ id: 'id',
+ type: 'type',
+ name: 'name',
+ lookupOptions: 'lookup_options',
+ })
+ .whereNull('deleted_time')
+ .whereRaw(`json_extract(lookup_options, '$."${optionsKey}"') = ?`, [value])
+ .toQuery();
+ }
+
+ optionsQuery(type: FieldType, optionsKey: string, value: string): string {
+ return this.knex('field')
+ .select({
+ tableId: 'table_id',
+ id: 'id',
+ name: 'name',
+ description: 'description',
+ notNull: 'not_null',
+ unique: 'unique',
+ isPrimary: 'is_primary',
+ dbFieldName: 'db_field_name',
+ isComputed: 'is_computed',
+ isPending: 'is_pending',
+ hasError: 'has_error',
+ dbFieldType: 'db_field_type',
+ isMultipleCellValue: 'is_multiple_cell_value',
+ isLookup: 'is_lookup',
+ lookupOptions: 'lookup_options',
+ type: 'type',
+ options: 'options',
+ cellValueType: 'cell_value_type',
+ })
+ .where('type', type)
+ .whereNull('is_lookup')
+ .whereNull('deleted_time')
+ .whereRaw(`json_extract(options, '$."${optionsKey}"') = ?`, [value])
+ .toQuery();
+ }
+
+ searchBuilder(qb: Knex.QueryBuilder, search: [string, string][]): Knex.QueryBuilder {
+ return qb.where((builder) => {
+ search.forEach(([field, value]) => {
+ builder.orWhereRaw('LOWER(??) LIKE LOWER(?)', [field, `%${value}%`]);
+ });
+ });
+ }
+
+ getTableIndexes(dbTableName: string): string {
+ return this.knex
+ .raw(
+ `SELECT
+ s.name AS name,
+ (SELECT "unique" FROM pragma_index_list(s.tbl_name) WHERE name = s.name) AS isUnique,
+ (SELECT json_group_array(name) FROM pragma_index_info(s.name) ORDER BY seqno) AS columns
+FROM
+ sqlite_schema AS s
+WHERE
+ s.type = 'index'
+ AND s.tbl_name = ?
+ORDER BY
+ s.name;`,
+ [dbTableName]
+ )
+ .toQuery();
+ }
+
+ generatedColumnQuery(): IGeneratedColumnQueryInterface {
+ return new GeneratedColumnQuerySqlite();
+ }
+ convertFormulaToGeneratedColumn(
+ expression: string,
+ context: IFormulaConversionContext
+ ): IFormulaConversionResult {
+ try {
+ const generatedColumnQuery = this.generatedColumnQuery();
+ // Set the context with driver client information
+ const contextWithDriver = { ...context, driverClient: this.driver };
+ generatedColumnQuery.setContext(contextWithDriver);
+
+ const visitor = new GeneratedColumnSqlConversionVisitor(
+ this.knex,
+ generatedColumnQuery,
+ contextWithDriver
+ );
+
+ const sql = parseFormulaToSQL(expression, visitor);
+
+ return visitor.getResult(sql);
+ } catch (error) {
+ throw new Error(`Failed to convert formula: ${(error as Error).message}`);
+ }
+ }
+
+ selectQuery(): ISelectQueryInterface {
+ return new SelectQuerySqlite();
+ }
+
+ convertFormulaToSelectQuery(
+ expression: string,
+ context: ISelectFormulaConversionContext
+ ): string {
+ try {
+ const selectQuery = this.selectQuery();
+ // Set the context with driver client information
+ const contextWithDriver = { ...context, driverClient: this.driver };
+ selectQuery.setContext(contextWithDriver);
+
+ const visitor = new SelectColumnSqlConversionVisitor(
+ this.knex,
+ selectQuery,
+ contextWithDriver
+ );
+
+ return parseFormulaToSQL(expression, visitor);
+ } catch (error) {
+ throw new Error(`Failed to convert formula: ${(error as Error).message}`);
+ }
+ }
+
+ generateDatabaseViewName(tableId: string): string {
+ return tableId + '_view';
+ }
+
+ createDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[] {
+ const viewName = this.generateDatabaseViewName(table.id);
+ return [this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery()];
+ }
+
+ recreateDatabaseView(table: TableDomain, qb: Knex.QueryBuilder): string[] {
+ const viewName = this.generateDatabaseViewName(table.id);
+ return [
+ this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery(),
+ this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery(),
+ ];
+ }
+
+ dropDatabaseView(tableId: string): string[] {
+ const viewName = this.generateDatabaseViewName(tableId);
+ return [this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery()];
+ }
+
+ // SQLite views are not materialized; nothing to refresh
+ refreshDatabaseView(_tableId: string): string | undefined {
+ return undefined;
+ }
+
+ createMaterializedView(table: TableDomain, qb: Knex.QueryBuilder): string {
+ const viewName = this.generateDatabaseViewName(table.id);
+ return this.knex.raw(`CREATE VIEW ?? AS ${qb.toQuery()}`, [viewName]).toQuery();
+ }
+
+ dropMaterializedView(tableId: string): string {
+ const viewName = this.generateDatabaseViewName(tableId);
+ return this.knex.raw(`DROP VIEW IF EXISTS ??`, [viewName]).toQuery();
}
}
diff --git a/apps/nestjs-backend/src/db-provider/utils/datetime-format.util.ts b/apps/nestjs-backend/src/db-provider/utils/datetime-format.util.ts
new file mode 100644
index 0000000000..01f47a28e5
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/utils/datetime-format.util.ts
@@ -0,0 +1,14 @@
+export {
+ DATETIME_FORMAT_SQL_BUILDERS,
+ DATETIME_FORMAT_TOKEN_TO_POSTGRES,
+ DEFAULT_DATETIME_FORMAT_EXPR,
+ DEFAULT_DATETIME_FORMAT_LITERAL,
+ LOCALIZED_DATETIME_FORMAT_MAP,
+ buildDatetimeFormatSql,
+ buildDatetimeParseGuardRegex,
+ expandLocalizedDatetimeFormat,
+ hasDatetimeTimezoneToken,
+ normalizeDatetimeFormatExpression,
+ type ILocalizedDatetimeFormatToken,
+ type ISupportedDatetimeFormatToken,
+} from '@teable/formula';
diff --git a/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts
new file mode 100644
index 0000000000..a85f1d595c
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.spec.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+
+import { getDefaultDatetimeParsePattern } from './default-datetime-parse-pattern';
+
+describe('default datetime parse pattern', () => {
+ it('accepts 1-digit hour in ISO-like datetimes', () => {
+ const pattern = new RegExp(getDefaultDatetimeParsePattern());
+ expect(pattern.test('2025-11-01 8:40')).toBe(true);
+ expect(pattern.test('2025-11-01 08:40')).toBe(true);
+ });
+
+ it('accepts single-digit month and day', () => {
+ const pattern = new RegExp(getDefaultDatetimeParsePattern());
+ // Single-digit month
+ expect(pattern.test('2026-9-15')).toBe(true);
+ expect(pattern.test('2026-1-15')).toBe(true);
+ // Single-digit day
+ expect(pattern.test('2026-09-5')).toBe(true);
+ expect(pattern.test('2026-12-1')).toBe(true);
+ // Both single-digit
+ expect(pattern.test('2026-9-5')).toBe(true);
+ expect(pattern.test('2026-1-1')).toBe(true);
+ // Double-digit (still works)
+ expect(pattern.test('2026-09-15')).toBe(true);
+ expect(pattern.test('2026-12-31')).toBe(true);
+ });
+
+ it('treats blank strings as invalid', () => {
+ const pattern = new RegExp(getDefaultDatetimeParsePattern());
+ expect(pattern.test('')).toBe(false);
+ expect(pattern.test(' ')).toBe(false);
+ });
+});
diff --git a/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts
new file mode 100644
index 0000000000..35e1b77e0a
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/utils/default-datetime-parse-pattern.ts
@@ -0,0 +1,19 @@
+/**
+ * Shared default pattern used to guard DATETIME_PARSE inputs.
+ * The expression must not contain any literal '?' characters because Knex
+ * would misinterpret them as parameter placeholders when embedding the regex.
+ */
+export const DEFAULT_DATETIME_PARSE_PATTERN = (() => {
+ const optional = (expr: string) => `(${expr}|)`;
+ const digitPair = '[0-9]{2}';
+ const hour = '[0-9]{1,2}';
+ const fractionalSeconds = '[.][0-9]{1,6}';
+ const secondSegment = ':' + digitPair + optional(fractionalSeconds);
+ const timeZoneSegment = `(Z|[+-]${digitPair}|[+-]${digitPair}${digitPair}|[+-]${digitPair}:${digitPair})`;
+ const timePart = `[ T]${hour}:${digitPair}` + optional(secondSegment) + optional(timeZoneSegment);
+
+ // Support both single-digit (e.g., 2026-9-15) and double-digit (e.g., 2026-09-15) month/day
+ return '^' + '[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}' + optional(timePart) + '$';
+})();
+
+export const getDefaultDatetimeParsePattern = (): string => DEFAULT_DATETIME_PARSE_PATTERN;
diff --git a/apps/nestjs-backend/src/db-provider/utils/formula-param-metadata.util.ts b/apps/nestjs-backend/src/db-provider/utils/formula-param-metadata.util.ts
new file mode 100644
index 0000000000..fb9596814d
--- /dev/null
+++ b/apps/nestjs-backend/src/db-provider/utils/formula-param-metadata.util.ts
@@ -0,0 +1,150 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { DbFieldType } from '@teable/core';
+import type { FormulaParamType, IFormulaParamMetadata } from '@teable/core';
+
+export interface IResolvedFormulaParamInfo {
+ hasMetadata: boolean;
+ type?: FormulaParamType;
+ isFieldReference: boolean;
+ isMultiValueField: boolean;
+ isJsonField: boolean;
+ fieldDbName?: string;
+ fieldDbType?: DbFieldType;
+ fieldCellValueType?: string;
+}
+
+const EMPTY_INFO: IResolvedFormulaParamInfo = {
+ hasMetadata: false,
+ type: undefined,
+ isFieldReference: false,
+ isMultiValueField: false,
+ isJsonField: false,
+ fieldDbName: undefined,
+ fieldDbType: undefined,
+ fieldCellValueType: undefined,
+};
+
+export function resolveFormulaParamInfo(
+ metadataList: IFormulaParamMetadata[] | undefined,
+ index?: number
+): IResolvedFormulaParamInfo {
+ if (index == null || !metadataList) {
+ return EMPTY_INFO;
+ }
+
+ const metadata = metadataList[index];
+ if (!metadata) {
+ return EMPTY_INFO;
+ }
+
+ const field = metadata.field;
+ const info: IResolvedFormulaParamInfo = {
+ hasMetadata: true,
+ type: metadata.type && metadata.type !== 'unknown' ? metadata.type : undefined,
+ isFieldReference: Boolean(metadata.isFieldReference && field),
+ isMultiValueField: Boolean(field?.isMultiple),
+ isJsonField: field?.dbFieldType === DbFieldType.Json,
+ fieldDbName: field?.dbFieldName,
+ fieldDbType: field?.dbFieldType,
+ fieldCellValueType: field?.cellValueType,
+ };
+
+ if (field?.isLookup && field.dbFieldType === DbFieldType.Json) {
+ info.isJsonField = true;
+ info.isMultiValueField = true;
+ }
+
+ if (!info.type) {
+ info.type = inferTypeFromField(field);
+ }
+
+ if (info.isJsonField && !info.type) {
+ info.type = 'string';
+ }
+
+ return info;
+}
+
+export function isTrustedNumeric(info: IResolvedFormulaParamInfo): boolean {
+ return info.type === 'number' && !info.isJsonField && !info.isMultiValueField;
+}
+
+export function isTextLikeParam(info: IResolvedFormulaParamInfo): boolean {
+ if (info.type !== 'string') {
+ return false;
+ }
+ if (!info.isJsonField) {
+ return true;
+ }
+ if (info.isMultiValueField) {
+ return false;
+ }
+ if (info.fieldCellValueType && info.fieldCellValueType !== 'string') {
+ return false;
+ }
+ return true;
+}
+
+export function isDatetimeLikeParam(info: IResolvedFormulaParamInfo): boolean {
+ return info.type === 'datetime';
+}
+
+export function isBooleanLikeParam(info: IResolvedFormulaParamInfo): boolean {
+ if (info.isJsonField) {
+ return false;
+ }
+
+ return (
+ info.type === 'boolean' ||
+ info.fieldDbType === DbFieldType.Boolean ||
+ info.fieldCellValueType === 'boolean'
+ );
+}
+
+export function isJsonLikeParam(info: IResolvedFormulaParamInfo): boolean {
+ return info.isJsonField || info.isMultiValueField;
+}
+
+function inferTypeFromField(field?: IFormulaParamMetadata['field']): FormulaParamType | undefined {
+ if (!field || field.isMultiple) {
+ return undefined;
+ }
+
+ const byDbType = mapDbFieldType(field.dbFieldType);
+ if (byDbType) {
+ return byDbType;
+ }
+
+ if (!field.cellValueType) {
+ return undefined;
+ }
+
+ switch (field.cellValueType) {
+ case 'number':
+ return 'number';
+ case 'boolean':
+ return 'boolean';
+ case 'datetime':
+ return 'datetime';
+ case 'string':
+ return 'string';
+ default:
+ return undefined;
+ }
+}
+
+function mapDbFieldType(dbFieldType?: DbFieldType): FormulaParamType | undefined {
+ switch (dbFieldType) {
+ case DbFieldType.Integer:
+ case DbFieldType.Real:
+ return 'number';
+ case DbFieldType.Boolean:
+ return 'boolean';
+ case DbFieldType.DateTime:
+ return 'datetime';
+ case DbFieldType.Text:
+ return 'string';
+ default:
+ return undefined;
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts b/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts
index 6924f60051..6c330b1ea8 100644
--- a/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts
+++ b/apps/nestjs-backend/src/event-emitter/decorators/emit-controller-event.decorator.ts
@@ -4,19 +4,9 @@ import { SetMetadata, UseInterceptors } from '@nestjs/common';
import type { Events } from '../events';
import { EventMiddleware } from '../interceptor/event.Interceptor';
-type OrdinaryEventName = Extract<
- Events,
- | Events.BASE_CREATE
- | Events.BASE_DELETE
- | Events.BASE_UPDATE
- | Events.SPACE_CREATE
- | Events.SPACE_DELETE
- | Events.SPACE_UPDATE
->;
-
export const EMIT_EVENT_NAME = 'EMIT_EVENT_NAME';
-export function EmitControllerEvent(name: OrdinaryEventName): MethodDecorator {
+export function EmitControllerEvent(name: Events): MethodDecorator {
return (target: any, key: string | symbol, descriptor: TypedPropertyDescriptor) => {
SetMetadata(EMIT_EVENT_NAME, name)(target, key, descriptor);
UseInterceptors(EventMiddleware)(target, key, descriptor);
diff --git a/apps/nestjs-backend/src/event-emitter/event-emitter.module.ts b/apps/nestjs-backend/src/event-emitter/event-emitter.module.ts
index f18390189a..84abeba5b9 100644
--- a/apps/nestjs-backend/src/event-emitter/event-emitter.module.ts
+++ b/apps/nestjs-backend/src/event-emitter/event-emitter.module.ts
@@ -4,11 +4,16 @@ import { ConfigurableModuleBuilder, Module } from '@nestjs/common';
import { EventEmitterModule as BaseEventEmitterModule } from '@nestjs/event-emitter';
import { AttachmentsTableModule } from '../features/attachments/attachments-table.module';
import { NotificationModule } from '../features/notification/notification.module';
+import { RecordModule } from '../features/record/record.module';
import { ShareDbModule } from '../share-db/share-db.module';
import { EventEmitterService } from './event-emitter.service';
import { ActionTriggerListener } from './listeners/action-trigger.listener';
import { AttachmentListener } from './listeners/attachment.listener';
+import { BasePermissionUpdateListener } from './listeners/base-permission-update.listener';
import { CollaboratorNotificationListener } from './listeners/collaborator-notification.listener';
+import { PinListener } from './listeners/pin.listener';
+import { RecordHistoryListener } from './listeners/record-history.listener';
+import { TrashListener } from './listeners/trash.listener';
export interface EventEmitterModuleOptions {
global?: boolean;
@@ -28,7 +33,7 @@ export class EventEmitterModule extends EventEmitterModuleClass {
});
return {
- imports: [module, ShareDbModule, NotificationModule, AttachmentsTableModule],
+ imports: [module, ShareDbModule, NotificationModule, AttachmentsTableModule, RecordModule],
module: EventEmitterModule,
global,
providers: [
@@ -36,6 +41,10 @@ export class EventEmitterModule extends EventEmitterModuleClass {
ActionTriggerListener,
CollaboratorNotificationListener,
AttachmentListener,
+ BasePermissionUpdateListener,
+ PinListener,
+ RecordHistoryListener,
+ TrashListener,
],
exports: [EventEmitterService],
};
diff --git a/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts b/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts
index 43d0df9df1..939b7d25e7 100644
--- a/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts
+++ b/apps/nestjs-backend/src/event-emitter/event-emitter.service.ts
@@ -1,6 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
-import type { ICreateOpBuilder, IOpBuilder, IOpContextBase, IOtOperation } from '@teable/core';
+import type {
+ ICreateOpBuilder,
+ IOpBuilder,
+ IOpContextBase,
+ IOtOperation,
+ IRecord,
+} from '@teable/core';
import {
FieldOpBuilder,
IdPrefix,
@@ -63,15 +69,15 @@ export class EventEmitterService {
};
constructor(
- private readonly eventEmitter: EventEmitter2,
+ public readonly eventEmitter: EventEmitter2,
private readonly cls: ClsService
) {}
- emit(event: string, data: unknown | unknown[]): boolean {
+ emit(event: string, data: T): boolean {
return this.eventEmitter.emit(event, data);
}
- emitAsync(event: string, data: unknown | unknown[]): Promise {
+ emitAsync(event: string, data: T): Promise {
return this.eventEmitter.emitAsync(event, data);
}
@@ -81,18 +87,20 @@ export class EventEmitterService {
if (!generatedEvents) {
return;
}
-
const observable = from(Array.from(generatedEvents.values()));
observable
.pipe(
- groupBy((event) => event.name),
+ groupBy((event) => {
+ const tableId = get(event, 'payload.tableId');
+ return tableId ? `${tableId}_${event.name}` : event.name;
+ }),
mergeMap((project) => this.aggregateEventsByGroup(project))
)
.subscribe((next) => this.handleEventResult(next));
}
- private aggregateEventsByGroup(project: GroupedObservable): Observable {
+ private aggregateEventsByGroup(project: GroupedObservable): Observable {
return project.pipe(
toArray(),
map((groupedEvents) => this.combineEvents(groupedEvents)),
@@ -105,7 +113,6 @@ export class EventEmitterService {
private combineEvents(groupedEvents: OpEvent[]): OpEvent {
if (groupedEvents.length <= 1) return groupedEvents[0];
-
return groupedEvents.reduce((combinedEvent, event, index) => {
const mergePropertyName = this.getMergePropertyName(event);
@@ -121,7 +128,11 @@ export class EventEmitterService {
private getMergePropertyName(event: OpEvent): string {
return match(event)
- .with({ name: Events.TABLE_VIEW_CREATE }, () => 'view')
+ .with(
+ P.union({ name: Events.TABLE_VIEW_CREATE }, { name: Events.TABLE_VIEW_UPDATE }),
+ () => 'view'
+ )
+ .with({ name: Events.TABLE_VIEW_DELETE }, () => 'viewId')
.with(
P.union({ name: Events.TABLE_FIELD_CREATE }, { name: Events.TABLE_FIELD_UPDATE }),
() => 'field'
@@ -149,7 +160,7 @@ export class EventEmitterService {
}
private handleEventResult(result: OpEvent): void {
- this.logger.debug({ eventName: result.name, eventList: result });
+ // this.logger.debug({ eventName: result.name, eventList: result });
this.emitAsync(result.name, result);
}
@@ -181,7 +192,6 @@ export class EventEmitterService {
opCreateData: rawOp.create?.data,
ops: rawOp?.op,
}) as OpEvent;
-
const event = this.createEvent(docType, opType, {
...extendPlainContext,
...plainContext,
@@ -191,21 +201,25 @@ export class EventEmitterService {
},
});
- event && this.mergeEventsForUpdate(eventManager, id, event);
+ if (event) {
+ this.mergeEventsForUpdate(eventManager, id, event);
+ }
}
}
}
private createExtendPlainContext(docId: string, id: string) {
const user = this.cls.get('user');
+ const entry = this.cls.get('entry');
return {
baseId: docId,
- tableId: docId,
+ tableId: id.startsWith(IdPrefix.Table) ? id : docId,
viewId: id,
fieldId: id,
recordId: id,
context: {
- user: user,
+ user,
+ entry,
},
};
}
@@ -229,16 +243,31 @@ export class EventEmitterService {
return;
}
- if (existingEvent.rawOpType === RawOpType.Create && event.name === Events.TABLE_RECORD_UPDATE) {
- const fields = this.getUpdateFieldsFromEvent(event as RecordUpdateEvent);
+ const { rawOpType } = existingEvent;
+
+ if (
+ [RawOpType.Create, RawOpType.Edit].includes(rawOpType) &&
+ event.name === Events.TABLE_RECORD_UPDATE
+ ) {
+ const fields = this.getUpdateFieldsFromEvent(event as RecordUpdateEvent, rawOpType);
event = this.combineUpdateEvents(existingEvent as RecordCreateEvent, fields);
}
eventManager.set(id, event);
}
- private getUpdateFieldsFromEvent(event: RecordUpdateEvent): { [key: string]: unknown } {
- return Object.entries((event.payload.record as IChangeRecord).fields).reduce(
+ private getUpdateFieldsFromEvent(
+ event: RecordUpdateEvent,
+ existedRawOpType: RawOpType
+ ): { [key: string]: unknown } {
+ const { payload } = event;
+ const fields = (payload.record as IChangeRecord).fields;
+
+ if (existedRawOpType === RawOpType.Edit) {
+ return fields;
+ }
+
+ return Object.entries(fields).reduce(
(acc, [key, value]) => {
acc[key] = value.newValue;
return acc;
@@ -257,7 +286,10 @@ export class EventEmitterService {
...existingEvent.payload,
record: {
...existingEvent.payload.record,
- fields,
+ fields: {
+ ...(existingEvent.payload.record as IRecord).fields,
+ ...fields,
+ },
},
},
};
@@ -269,6 +301,12 @@ export class EventEmitterService {
const eventName = this.eventNameMapping[action]?.[docType];
if (!eventName) return undefined;
+ const oldField = this.cls.get('oldField');
+
+ if (eventName === Events.TABLE_RECORD_UPDATE) {
+ payload.oldField = oldField;
+ }
+
return match(docType)
.with(IdPrefix.Table, () => TableEventFactory.create(eventName, payload, context))
.with(IdPrefix.Field, () => FieldEventFactory.create(eventName, payload, context))
diff --git a/apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts b/apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts
new file mode 100644
index 0000000000..51ec559dee
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/event-job/event-job.module.ts
@@ -0,0 +1,43 @@
+import { BullModule } from '@nestjs/bullmq';
+import type { NestWorkerOptions } from '@nestjs/bullmq/dist/interfaces/worker-options.interface';
+import type { DynamicModule } from '@nestjs/common';
+import { Module } from '@nestjs/common';
+import { ConditionalModule } from '@nestjs/config';
+import { ConfigModule } from '../../configs/config.module';
+import { FallbackQueueModule } from './fallback/fallback-queue.module';
+
+const queueOptions: NestWorkerOptions = {
+ removeOnComplete: {
+ count: 2000,
+ },
+ removeOnFail: {
+ count: 5000,
+ },
+};
+
+@Module({
+ imports: [ConfigModule],
+})
+export class EventJobModule {
+ static async registerQueue(name: string): Promise {
+ const [bullQueue, fallbackQueue] = await Promise.all([
+ ConditionalModule.registerWhen(
+ BullModule.registerQueue({
+ name,
+ ...queueOptions,
+ }),
+ (env) => Boolean(env.BACKEND_CACHE_REDIS_URI)
+ ),
+ ConditionalModule.registerWhen(
+ FallbackQueueModule.registerQueue(name),
+ (env) => !env.BACKEND_CACHE_REDIS_URI
+ ),
+ ]);
+
+ return {
+ module: EventJobModule,
+ imports: [bullQueue, fallbackQueue],
+ exports: [bullQueue, fallbackQueue],
+ };
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/event-job/fallback/event-emitter.ts b/apps/nestjs-backend/src/event-emitter/event-job/fallback/event-emitter.ts
new file mode 100644
index 0000000000..fd036d7196
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/event-job/fallback/event-emitter.ts
@@ -0,0 +1,3 @@
+import EventEmitter from 'events';
+
+export const localQueueEventEmitter = new EventEmitter();
diff --git a/apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.module.ts b/apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.module.ts
new file mode 100644
index 0000000000..6ca431bc5d
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.module.ts
@@ -0,0 +1,18 @@
+import type { DynamicModule } from '@nestjs/common';
+import { Module } from '@nestjs/common';
+import { DiscoveryService } from '@nestjs/core';
+import { FallbackQueueService } from './fallback-queue.service';
+import { createLocalQueueProvider } from './local-queue.provider';
+
+@Module({})
+export class FallbackQueueModule {
+ static registerQueue(name: string): DynamicModule {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const LocalQueueProvider = createLocalQueueProvider(name);
+ return {
+ module: FallbackQueueModule,
+ providers: [FallbackQueueService, DiscoveryService, LocalQueueProvider],
+ exports: [LocalQueueProvider],
+ };
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.service.ts b/apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.service.ts
new file mode 100644
index 0000000000..870aa6935c
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/event-job/fallback/fallback-queue.service.ts
@@ -0,0 +1,77 @@
+import type { OnModuleInit } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
+import { Reflector, DiscoveryService } from '@nestjs/core';
+import type { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
+import { localQueueEventEmitter } from './event-emitter';
+import type { ILocalJob } from './local-queue.provider';
+
+export const PROCESSOR_METADATA = 'bullmq:processor_metadata';
+
+@Injectable()
+export class FallbackQueueService implements OnModuleInit {
+ private logger = new Logger(FallbackQueueService.name);
+ constructor(
+ private readonly reflector: Reflector,
+ private readonly discoveryService: DiscoveryService
+ ) {}
+
+ async onModuleInit() {
+ this.logger.debug('FallbackQueueService init');
+ this.collectionProcess();
+ }
+
+ collectionProcess() {
+ const providers: InstanceWrapper[] = this.discoveryService
+ .getProviders()
+ .filter((wrapper: InstanceWrapper) => {
+ const target =
+ !wrapper.metatype || wrapper.inject ? wrapper.instance?.constructor : wrapper.metatype;
+ if (!target) {
+ return false;
+ }
+ return !!this.reflector.get(PROCESSOR_METADATA, target);
+ });
+
+ providers.forEach((wrapper: InstanceWrapper) => {
+ const { instance, metatype } = wrapper;
+ if (!wrapper.isDependencyTreeStatic()) {
+ return;
+ }
+
+ const { name: queueName } = this.reflector.get(
+ PROCESSOR_METADATA,
+ instance.constructor || metatype
+ );
+ localQueueEventEmitter.removeAllListeners(`handle-listener-${queueName}`);
+ localQueueEventEmitter.on(`handle-listener-${queueName}`, (job: ILocalJob) => {
+ if (job.queueName !== queueName) {
+ return;
+ }
+ this.handleListener(wrapper, job);
+ });
+ });
+ }
+
+ private async handleListener(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ wrapper: InstanceWrapper,
+ job: ILocalJob
+ ) {
+ const { instance } = wrapper;
+ const methodName = 'process';
+ if (!instance[methodName]) {
+ this.logger.warn(`${instance.constructor.name} has no method ${methodName}`);
+ return;
+ }
+ try {
+ job.state = 'active';
+ const result = await instance[methodName].call(instance, job);
+ job.state = 'completed';
+ job.returnvalue = result;
+ } catch (error) {
+ job.state = 'failed';
+ job.failedReason = error instanceof Error ? error.message : String(error);
+ this.logger.error(`Error processing job ${job.name}:`, error);
+ }
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/event-job/fallback/local-queue.provider.ts b/apps/nestjs-backend/src/event-emitter/event-job/fallback/local-queue.provider.ts
new file mode 100644
index 0000000000..011bd6fcd8
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/event-job/fallback/local-queue.provider.ts
@@ -0,0 +1,69 @@
+import { getQueueToken } from '@nestjs/bullmq';
+import type { Provider } from '@nestjs/common';
+import { getRandomString } from '@teable/core';
+import type { JobsOptions } from 'bullmq';
+import { localQueueEventEmitter } from './event-emitter';
+
+export interface ILocalJob {
+ id: string;
+ name: string;
+ data: unknown;
+ opts?: JobsOptions;
+ queueName: string;
+ progress: number | object;
+ returnvalue: unknown;
+ failedReason?: string;
+ state: string;
+ getState: () => Promise;
+ updateProgress: (progress: number | object) => Promise;
+}
+
+export const createLocalQueueProvider = (queueName: string): Provider => ({
+ provide: getQueueToken(queueName),
+ useFactory: async () => {
+ const jobs = new Map();
+
+ const createJob = (id: string, name: string, data: unknown, opts?: JobsOptions): ILocalJob => {
+ const job: ILocalJob = {
+ id,
+ name,
+ data,
+ opts,
+ queueName,
+ progress: 0,
+ returnvalue: undefined,
+ failedReason: undefined,
+ state: 'waiting',
+ getState: async () => job.state,
+ updateProgress: async (p: number | object) => {
+ job.progress = p;
+ },
+ };
+ return job;
+ };
+
+ return {
+ add: (name: string, data: unknown, opts?: JobsOptions) => {
+ const id = opts?.jobId ?? getRandomString(10);
+ const job = createJob(id, name, data, opts);
+ jobs.set(id, job);
+ localQueueEventEmitter.emit(`handle-listener-${queueName}`, job);
+ return job;
+ },
+ addBulk: (bulkJobs: JobsOptions[]) => {
+ bulkJobs.forEach((job) => {
+ localQueueEventEmitter.emit(`handle-listener-${queueName}`, job);
+ });
+ },
+ getJob: async (jobId: string) => {
+ return jobs.get(jobId) ?? null;
+ },
+ getJobs: async () => {
+ return Array.from(jobs.values());
+ },
+ getJobCountByTypes: async () => {
+ return jobs.size;
+ },
+ };
+ },
+});
diff --git a/apps/nestjs-backend/src/event-emitter/events/app/app.event.ts b/apps/nestjs-backend/src/event-emitter/events/app/app.event.ts
new file mode 100644
index 0000000000..a0ae5ccb88
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/app/app.event.ts
@@ -0,0 +1,59 @@
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../core-event';
+import { CoreEvent } from '../core-event';
+import { Events } from '../event.enum';
+
+interface IAppVo {
+ id: string;
+ name: string;
+}
+
+type IAppCreatePayload = { baseId: string; app: IAppVo };
+type IAppDeletePayload = { baseId: string; appId: string; permanent?: boolean };
+type IAppUpdatePayload = { baseId: string; app: IAppVo };
+
+export class AppCreateEvent extends CoreEvent {
+ public readonly name = Events.APP_CREATE;
+
+ constructor(payload: IAppCreatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class AppDeleteEvent extends CoreEvent {
+ public readonly name = Events.APP_DELETE;
+ constructor(payload: IAppDeletePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class AppUpdateEvent extends CoreEvent {
+ public readonly name = Events.APP_UPDATE;
+
+ constructor(payload: IAppUpdatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class AppEventFactory {
+ static create(
+ name: string,
+ payload: IAppCreatePayload | IAppDeletePayload | IAppUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.APP_CREATE, () => {
+ const { baseId, app } = payload as IAppCreatePayload;
+ return new AppCreateEvent({ baseId, app }, context);
+ })
+ .with(Events.APP_UPDATE, () => {
+ const { baseId, app } = payload as IAppUpdatePayload;
+ return new AppUpdateEvent({ baseId, app }, context);
+ })
+ .with(Events.APP_DELETE, () => {
+ const { baseId, appId, permanent } = payload as IAppDeletePayload;
+ return new AppDeleteEvent({ baseId, appId, permanent }, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/base/base-node.event.ts b/apps/nestjs-backend/src/event-emitter/events/base/base-node.event.ts
new file mode 100644
index 0000000000..8b634468e1
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/base/base-node.event.ts
@@ -0,0 +1,171 @@
+import { BaseNodeResourceType, type IBaseNodeVo, type IDeleteBaseNodeVo } from '@teable/openapi';
+import { match } from 'ts-pattern';
+import { AppEventFactory } from '../app/app.event';
+import type { IEventContext } from '../core-event';
+import { DashboardEventFactory } from '../dashboard/dashboard.event';
+import { Events } from '../event.enum';
+import { WorkflowEventFactory } from '../workflow/workflow.event';
+import { BaseFolderEventFactory } from './folder/base.folder.event';
+
+type IBaseNodeCreatePayload = { baseId: string; node: IBaseNodeVo };
+type IBaseNodeDeletePayload = { baseId: string; node: IDeleteBaseNodeVo };
+type IBaseNodeUpdatePayload = IBaseNodeCreatePayload;
+
+// base node event to resource event(folder, dashboard, workflow, app); table event is handled by ops2Event;
+export class BaseNodeEventFactory {
+ static create(
+ name: string,
+ payload: IBaseNodeCreatePayload | IBaseNodeDeletePayload | IBaseNodeUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.BASE_NODE_CREATE, () => {
+ const { baseId, node } = payload as IBaseNodeCreatePayload;
+ const { resourceId, resourceType, resourceMeta } = node;
+ switch (resourceType) {
+ case BaseNodeResourceType.Folder:
+ return BaseFolderEventFactory.create(
+ Events.BASE_FOLDER_CREATE,
+ {
+ baseId,
+ folder: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+ case BaseNodeResourceType.Dashboard:
+ return DashboardEventFactory.create(
+ Events.DASHBOARD_CREATE,
+ {
+ baseId,
+ dashboard: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+ case BaseNodeResourceType.Workflow:
+ return WorkflowEventFactory.create(
+ Events.WORKFLOW_CREATE,
+ {
+ baseId,
+ workflow: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+ case BaseNodeResourceType.App:
+ return AppEventFactory.create(
+ Events.APP_CREATE,
+ {
+ baseId,
+ app: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+
+ default:
+ return null;
+ }
+ })
+ .with(Events.BASE_NODE_UPDATE, () => {
+ const { baseId, node } = payload as IBaseNodeUpdatePayload;
+ const { resourceId, resourceType, resourceMeta } = node;
+ switch (resourceType) {
+ case BaseNodeResourceType.Folder:
+ return BaseFolderEventFactory.create(
+ Events.BASE_FOLDER_UPDATE,
+ {
+ baseId,
+ folder: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+ case BaseNodeResourceType.Dashboard:
+ return DashboardEventFactory.create(
+ Events.DASHBOARD_UPDATE,
+ {
+ baseId,
+ dashboard: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+ case BaseNodeResourceType.Workflow:
+ return WorkflowEventFactory.create(
+ Events.WORKFLOW_UPDATE,
+ {
+ baseId,
+ workflow: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+ case BaseNodeResourceType.App:
+ return AppEventFactory.create(
+ Events.APP_UPDATE,
+ {
+ baseId,
+ app: {
+ id: resourceId,
+ ...resourceMeta,
+ },
+ },
+ context
+ );
+
+ default:
+ return null;
+ }
+ })
+ .with(Events.BASE_NODE_DELETE, () => {
+ const { baseId, node } = payload as IBaseNodeDeletePayload;
+ const { resourceId, resourceType, permanent } = node;
+ switch (resourceType) {
+ case BaseNodeResourceType.Folder:
+ return BaseFolderEventFactory.create(
+ Events.BASE_FOLDER_DELETE,
+ { baseId, folderId: resourceId },
+ context
+ );
+ case BaseNodeResourceType.Dashboard:
+ return DashboardEventFactory.create(
+ Events.DASHBOARD_DELETE,
+ { baseId, dashboardId: resourceId },
+ context
+ );
+ case BaseNodeResourceType.Workflow:
+ return WorkflowEventFactory.create(
+ Events.WORKFLOW_DELETE,
+ { baseId, workflowId: resourceId, permanent },
+ context
+ );
+ case BaseNodeResourceType.App:
+ return AppEventFactory.create(
+ Events.APP_DELETE,
+ { baseId, appId: resourceId, permanent },
+ context
+ );
+ default:
+ return null;
+ }
+ })
+
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/base/base.event.ts b/apps/nestjs-backend/src/event-emitter/events/base/base.event.ts
index 30cb52df1f..08fd03cd45 100644
--- a/apps/nestjs-backend/src/event-emitter/events/base/base.event.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/base/base.event.ts
@@ -5,8 +5,9 @@ import { CoreEvent } from '../core-event';
import { Events } from '../event.enum';
type IBaseCreatePayload = { base: ICreateBaseVo };
-type IBaseDeletePayload = { baseId: string };
+type IBaseDeletePayload = { baseId: string; permanent?: boolean };
type IBaseUpdatePayload = IBaseCreatePayload;
+type IBasePermissionUpdatePayload = { baseId: string };
export class BaseCreateEvent extends CoreEvent {
public readonly name = Events.BASE_CREATE;
@@ -18,8 +19,8 @@ export class BaseCreateEvent extends CoreEvent {
export class BaseDeleteEvent extends CoreEvent {
public readonly name = Events.BASE_DELETE;
- constructor(baseId: string, context: IEventContext) {
- super({ baseId }, context);
+ constructor(payload: IBaseDeletePayload, context: IEventContext) {
+ super(payload, context);
}
}
@@ -31,6 +32,14 @@ export class BaseUpdateEvent extends CoreEvent {
}
}
+export class BasePermissionUpdateEvent extends CoreEvent {
+ public readonly name = Events.BASE_PERMISSION_UPDATE;
+
+ constructor(baseId: string, context: IEventContext) {
+ super({ baseId }, context);
+ }
+}
+
export class BaseEventFactory {
static create(
name: string,
@@ -43,13 +52,17 @@ export class BaseEventFactory {
return new BaseCreateEvent(base, context);
})
.with(Events.BASE_DELETE, () => {
- const { baseId } = payload as IBaseDeletePayload;
- return new BaseDeleteEvent(baseId, context);
+ const { baseId, permanent } = payload as IBaseDeletePayload;
+ return new BaseDeleteEvent({ baseId, permanent }, context);
})
.with(Events.BASE_UPDATE, () => {
const { base } = payload as IBaseUpdatePayload;
return new BaseUpdateEvent(base, context);
})
+ .with(Events.BASE_PERMISSION_UPDATE, () => {
+ const { baseId } = payload as IBasePermissionUpdatePayload;
+ return new BasePermissionUpdateEvent(baseId, context);
+ })
.otherwise(() => null);
}
}
diff --git a/apps/nestjs-backend/src/event-emitter/events/base/folder/base.folder.event.ts b/apps/nestjs-backend/src/event-emitter/events/base/folder/base.folder.event.ts
new file mode 100644
index 0000000000..a30b0cc58a
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/base/folder/base.folder.event.ts
@@ -0,0 +1,59 @@
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../../core-event';
+import { CoreEvent } from '../../core-event';
+import { Events } from '../../event.enum';
+
+type IBaseFolder = {
+ id: string;
+ name: string;
+};
+
+type IBaseFolderCreatePayload = { baseId: string; folder: IBaseFolder };
+type IBaseFolderDeletePayload = { baseId: string; folderId: string };
+type IBaseFolderUpdatePayload = IBaseFolderCreatePayload;
+
+export class BaseFolderCreateEvent extends CoreEvent {
+ public readonly name = Events.BASE_FOLDER_CREATE;
+
+ constructor(payload: IBaseFolderCreatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class BaseFolderDeleteEvent extends CoreEvent {
+ public readonly name = Events.BASE_FOLDER_DELETE;
+ constructor(payload: IBaseFolderDeletePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class BaseFolderUpdateEvent extends CoreEvent {
+ public readonly name = Events.BASE_FOLDER_UPDATE;
+
+ constructor(payload: IBaseFolderUpdatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class BaseFolderEventFactory {
+ static create(
+ name: string,
+ payload: IBaseFolderCreatePayload | IBaseFolderDeletePayload | IBaseFolderUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.BASE_FOLDER_CREATE, () => {
+ const { baseId, folder } = payload as IBaseFolderCreatePayload;
+ return new BaseFolderCreateEvent({ baseId, folder }, context);
+ })
+ .with(Events.BASE_FOLDER_DELETE, () => {
+ const { baseId, folderId } = payload as IBaseFolderDeletePayload;
+ return new BaseFolderDeleteEvent({ baseId, folderId }, context);
+ })
+ .with(Events.BASE_FOLDER_UPDATE, () => {
+ const { baseId, folder } = payload as IBaseFolderUpdatePayload;
+ return new BaseFolderUpdateEvent({ baseId, folder }, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/core-event.ts b/apps/nestjs-backend/src/event-emitter/events/core-event.ts
index 2ee32073d6..b5616f9b8b 100644
--- a/apps/nestjs-backend/src/event-emitter/events/core-event.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/core-event.ts
@@ -1,5 +1,6 @@
import type { IncomingHttpHeaders } from 'http';
import type { OpName } from '@teable/core';
+import type { IUserInfoVo } from '@teable/openapi';
import { nanoid } from 'nanoid';
import type { Events } from './event.enum';
@@ -9,6 +10,10 @@ export interface IEventContext {
name: string;
email: string;
};
+ entry?: {
+ type: string;
+ id: string;
+ };
headers?: Record | IncomingHttpHeaders;
opMeta?: {
name: OpName;
@@ -16,6 +21,15 @@ export interface IEventContext {
};
}
+export interface IEventRawContext {
+ reqUser?: IUserInfoVo;
+ reqHeaders: Record;
+ reqParams?: unknown;
+ reqQuery?: unknown;
+ reqBody?: unknown;
+ resolveData: unknown;
+}
+
export abstract class CoreEvent {
abstract name: Events;
diff --git a/apps/nestjs-backend/src/event-emitter/events/dashboard/dashboard.event.ts b/apps/nestjs-backend/src/event-emitter/events/dashboard/dashboard.event.ts
new file mode 100644
index 0000000000..8d3f430410
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/dashboard/dashboard.event.ts
@@ -0,0 +1,52 @@
+import type { ICreateDashboardVo } from '@teable/openapi';
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../core-event';
+import { CoreEvent } from '../core-event';
+import { Events } from '../event.enum';
+
+type IDashboardCreatePayload = { baseId: string; dashboard: ICreateDashboardVo };
+type IDashboardUpdatePayload = { baseId: string; dashboard: ICreateDashboardVo };
+type IDashboardDeletePayload = { baseId: string; dashboardId: string; permanent?: boolean };
+
+export class DashboardCreateEvent extends CoreEvent {
+ public readonly name = Events.DASHBOARD_CREATE;
+
+ constructor(payload: IDashboardCreatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class DashboardDeleteEvent extends CoreEvent {
+ public readonly name = Events.DASHBOARD_DELETE;
+ constructor(payload: IDashboardDeletePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class DashboardUpdateEvent extends CoreEvent {
+ public readonly name = Events.DASHBOARD_UPDATE;
+
+ constructor(payload: IDashboardUpdatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class DashboardEventFactory {
+ static create(
+ name: string,
+ payload: IDashboardCreatePayload | IDashboardDeletePayload | IDashboardUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.DASHBOARD_CREATE, () => {
+ return new DashboardCreateEvent(payload as IDashboardCreatePayload, context);
+ })
+ .with(Events.DASHBOARD_DELETE, () => {
+ return new DashboardDeleteEvent(payload as IDashboardDeletePayload, context);
+ })
+ .with(Events.DASHBOARD_UPDATE, () => {
+ return new DashboardUpdateEvent(payload as IDashboardUpdatePayload, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts
index a04d05f885..83faf2375e 100644
--- a/apps/nestjs-backend/src/event-emitter/events/event.enum.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/event.enum.ts
@@ -7,9 +7,14 @@ export enum Events {
BASE_CREATE = 'base.create',
BASE_DELETE = 'base.delete',
BASE_UPDATE = 'base.update',
+ BASE_PERMISSION_UPDATE = 'base.permission.update',
// BASE_CLONE = 'base.clone',
// BASE_MOVE = 'base.move',
+ BASE_NODE_CREATE = 'base.node.create',
+ BASE_NODE_DELETE = 'base.node.delete',
+ BASE_NODE_UPDATE = 'base.node.update',
+
TABLE_CREATE = 'table.create',
TABLE_DELETE = 'table.delete',
TABLE_UPDATE = 'table.update',
@@ -22,21 +27,91 @@ export enum Events {
TABLE_RECORD_DELETE = 'table.record.delete',
TABLE_RECORD_UPDATE = 'table.record.update',
+ TABLE_BUTTON_CLICK = 'table.button.click',
+
TABLE_VIEW_CREATE = 'table.view.create',
TABLE_VIEW_DELETE = 'table.view.delete',
TABLE_VIEW_UPDATE = 'table.view.update',
+ OPERATION_RECORDS_CREATE = 'operation.records.create',
+ OPERATION_RECORDS_DELETE = 'operation.records.delete',
+ OPERATION_RECORDS_UPDATE = 'operation.records.update',
+ OPERATION_RECORDS_ORDER_UPDATE = 'operation.records.order.update',
+ OPERATION_FIELDS_CREATE = 'operation.fields.create',
+ OPERATION_FIELDS_DELETE = 'operation.fields.delete',
+ OPERATION_FIELD_CONVERT = 'operation.field.convert',
+ OPERATION_PASTE_SELECTION = 'operation.paste.selection',
+ OPERATION_VIEW_DELETE = 'operation.view.delete',
+ OPERATION_VIEW_CREATE = 'operation.view.create',
+ OPERATION_VIEW_UPDATE = 'operation.view.update',
+ OPERATION_PUSH = 'operation.push',
+
+ TABLE_USER_RENAME_COMPLETE = 'table.user.rename.complete',
+
SHARED_VIEW_CREATE = 'shared.view.create',
SHARED_VIEW_DELETE = 'shared.view.delete',
SHARED_VIEW_UPDATE = 'shared.view.update',
USER_SIGNIN = 'user.signin',
USER_SIGNUP = 'user.signup',
+ USER_RENAME = 'user.rename',
USER_SIGNOUT = 'user.signout',
- USER_UPDATE = 'user.update',
USER_DELETE = 'user.delete',
// USER_PASSWORD_RESET = 'user.password.reset',
USER_PASSWORD_CHANGE = 'user.password.change',
// USER_PASSWORD_FORGOT = 'user.password.forgot'
+ USER_EMAIL_CHANGE = 'user.email.change',
+
+ COLLABORATOR_CREATE = 'collaborator.create',
+ COLLABORATOR_DELETE = 'collaborator.delete',
+ COLLABORATOR_UPDATE = 'collaborator.update',
+
+ BASE_FOLDER_CREATE = 'base.folder.create',
+ BASE_FOLDER_DELETE = 'base.folder.delete',
+ BASE_FOLDER_UPDATE = 'base.folder.update',
+
+ DASHBOARD_CREATE = 'dashboard.create',
+ DASHBOARD_DELETE = 'dashboard.delete',
+ DASHBOARD_UPDATE = 'dashboard.update',
+
+ WORKFLOW_CREATE = 'workflow.create',
+ WORKFLOW_DELETE = 'workflow.delete',
+ WORKFLOW_UPDATE = 'workflow.update',
+ WORKFLOW_ACTIVATE = 'workflow.activate',
+ WORKFLOW_DEACTIVATE = 'workflow.deactivate',
+
+ APP_CREATE = 'app.create',
+ APP_DELETE = 'app.delete',
+ APP_UPDATE = 'app.update',
+
+ CROP_IMAGE = 'crop.image',
+ CROP_IMAGE_COMPLETE = 'crop.image.complete',
+
+ RECORD_HISTORY_CREATE = 'record.history.create',
+
+ // following make no sense just for testing
+ BASE_EXPORT_COMPLETE = 'base.export.complete',
+
+ LAST_VISIT_CLEAR = 'last.visit.clear',
+ LAST_VISIT_UPDATE = 'last.visit.update',
+
+ AUDIT_LOG_SAVED = 'audit-log.saved',
+
+ NOTIFY_MAIL_MERGE = 'notify.mail.merge',
+
+ // record source
+ TABLE_RECORD_CREATE_RELATIVE = 'table.record.create.relative',
+
+ // Invitation funnel
+ INVITATION_EMAIL_SEND = 'invitation.email.send',
+ INVITATION_LINK_CREATE = 'invitation.link.create',
+ INVITATION_ACCEPT = 'invitation.accept',
+
+ // Access token lifecycle
+ ACCESS_TOKEN_CREATE = 'access-token.create',
+ ACCESS_TOKEN_DELETE = 'access-token.delete',
+
+ // Table export
+ TABLE_EXPORT = 'table.export',
}
diff --git a/apps/nestjs-backend/src/event-emitter/events/index.ts b/apps/nestjs-backend/src/event-emitter/events/index.ts
index 374b83d347..ba9e007efd 100644
--- a/apps/nestjs-backend/src/event-emitter/events/index.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/index.ts
@@ -2,5 +2,10 @@ export * from './event.enum';
export * from './core-event';
export * from './op-event';
export * from './base/base.event';
+export * from './base/folder/base.folder.event';
export * from './space/space.event';
+export * from './space/collaborator.event';
export * from './table';
+export * from './dashboard/dashboard.event';
+export * from './workflow/workflow.event';
+export * from './app/app.event';
diff --git a/apps/nestjs-backend/src/event-emitter/events/last-visit/last-visit.event.ts b/apps/nestjs-backend/src/event-emitter/events/last-visit/last-visit.event.ts
new file mode 100644
index 0000000000..c8ad416744
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/last-visit/last-visit.event.ts
@@ -0,0 +1,8 @@
+import type { IUpdateUserLastVisitRo } from '@teable/openapi';
+import { Events } from '../event.enum';
+
+export class LastVisitUpdateEvent {
+ public readonly name = Events.LAST_VISIT_UPDATE;
+
+ constructor(public readonly payload: IUpdateUserLastVisitRo) {}
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts b/apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts
new file mode 100644
index 0000000000..6ed236889b
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/space/collaborator.event.ts
@@ -0,0 +1,19 @@
+import { Events } from '../event.enum';
+
+export class CollaboratorCreateEvent {
+ public readonly name = Events.COLLABORATOR_CREATE;
+
+ constructor(public readonly spaceId: string) {}
+}
+
+export class CollaboratorDeleteEvent {
+ public readonly name = Events.COLLABORATOR_DELETE;
+
+ constructor(public readonly spaceId: string) {}
+}
+
+export class CollaboratorUpdateEvent {
+ public readonly name = Events.COLLABORATOR_UPDATE;
+
+ constructor(public readonly spaceId: string) {}
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/space/space.event.ts b/apps/nestjs-backend/src/event-emitter/events/space/space.event.ts
index 12000967d3..b05a75f07f 100644
--- a/apps/nestjs-backend/src/event-emitter/events/space/space.event.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/space/space.event.ts
@@ -5,7 +5,7 @@ import { CoreEvent } from '../core-event';
import { Events } from '../event.enum';
type ISpaceCreatePayload = { space: ICreateSpaceVo };
-type ISpaceDeletePayload = { spaceId: string };
+type ISpaceDeletePayload = { spaceId: string; permanent?: boolean };
type ISpaceUpdatePayload = ISpaceCreatePayload;
export class SpaceCreateEvent extends CoreEvent {
@@ -19,8 +19,8 @@ export class SpaceCreateEvent extends CoreEvent {
export class SpaceDeleteEvent extends CoreEvent {
public readonly name = Events.SPACE_DELETE;
- constructor(spaceId: string, context: IEventContext) {
- super({ spaceId }, context);
+ constructor(payload: ISpaceDeletePayload, context: IEventContext) {
+ super(payload, context);
}
}
@@ -44,8 +44,8 @@ export class SpaceEventFactory {
return new SpaceCreateEvent(space, context);
})
.with(Events.SPACE_DELETE, () => {
- const { spaceId } = payload as ISpaceDeletePayload;
- return new SpaceDeleteEvent(spaceId, context);
+ const { spaceId, permanent } = payload as ISpaceDeletePayload;
+ return new SpaceDeleteEvent({ spaceId, permanent }, context);
})
.with(Events.SPACE_UPDATE, () => {
const { space } = payload as ISpaceUpdatePayload;
diff --git a/apps/nestjs-backend/src/event-emitter/events/table/button.event.ts b/apps/nestjs-backend/src/event-emitter/events/table/button.event.ts
new file mode 100644
index 0000000000..6c96d4e315
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/table/button.event.ts
@@ -0,0 +1,28 @@
+import type { IRecord } from '@teable/core';
+import { match } from 'ts-pattern';
+import { CoreEvent, type IEventContext } from '../core-event';
+import { Events } from '../event.enum';
+
+type IButtonClickEventPayload = {
+ tableId: string;
+ fieldId: string;
+ record: IRecord;
+};
+
+export class ButtonClickEvent extends CoreEvent {
+ public readonly name = Events.TABLE_BUTTON_CLICK;
+
+ constructor(payload: IButtonClickEventPayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class ButtonEventFactory {
+ static create(name: string, payload: IButtonClickEventPayload, context: IEventContext) {
+ return match(name)
+ .with(Events.TABLE_BUTTON_CLICK, () => {
+ return new ButtonClickEvent(payload, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/table/index.ts b/apps/nestjs-backend/src/event-emitter/events/table/index.ts
index 2807c5940e..1eb7935d88 100644
--- a/apps/nestjs-backend/src/event-emitter/events/table/index.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/table/index.ts
@@ -2,3 +2,4 @@ export * from './table.event';
export * from './field.event';
export * from './view.event';
export * from './record.event';
+export * from './button.event';
diff --git a/apps/nestjs-backend/src/event-emitter/events/table/record.event.ts b/apps/nestjs-backend/src/event-emitter/events/table/record.event.ts
index d86960490c..4d581839de 100644
--- a/apps/nestjs-backend/src/event-emitter/events/table/record.event.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/table/record.event.ts
@@ -1,4 +1,4 @@
-import type { IRecord } from '@teable/core';
+import type { IFieldVo, IRecord } from '@teable/core';
import { match } from 'ts-pattern';
import { RawOpType } from '../../../share-db/interface';
import type { IEventContext } from '../core-event';
@@ -6,10 +6,7 @@ import { Events } from '../event.enum';
import type { IChangeValue } from '../op-event';
import { OpEvent } from '../op-event';
-export type IChangeRecord = Record<
- keyof Pick,
- Record
-> & {
+export type IChangeRecord = Record, Record> & {
id: string;
};
@@ -18,8 +15,20 @@ type IRecordDeletePayload = { tableId: string; recordId: string | string[] };
type IRecordUpdatePayload = {
tableId: string;
record: IChangeRecord | IChangeRecord[];
+ oldField: IFieldVo | undefined;
};
+export function getFieldIdsFromRecord(record: IRecord | IRecord[]) {
+ const records = Array.isArray(record) ? record : [record];
+ const fieldIds: string[] = [];
+ for (const r of records) {
+ if (r?.fields) {
+ fieldIds.push(...Object.keys(r.fields));
+ }
+ }
+ return fieldIds;
+}
+
export class RecordCreateEvent extends OpEvent {
public readonly name = Events.TABLE_RECORD_CREATE;
public readonly rawOpType = RawOpType.Create;
@@ -42,8 +51,13 @@ export class RecordUpdateEvent extends OpEvent {
public readonly name = Events.TABLE_RECORD_UPDATE;
public readonly rawOpType = RawOpType.Edit;
- constructor(tableId: string, record: IChangeRecord | IChangeRecord[], context: IEventContext) {
- super({ tableId, record }, context, Array.isArray(record));
+ constructor(
+ tableId: string,
+ record: IChangeRecord | IChangeRecord[],
+ oldField: IFieldVo | undefined,
+ context: IEventContext
+ ) {
+ super({ tableId, record, oldField }, context, Array.isArray(record));
}
}
@@ -63,8 +77,8 @@ export class RecordEventFactory {
return new RecordDeleteEvent(tableId, recordId, context);
})
.with(Events.TABLE_RECORD_UPDATE, () => {
- const { tableId, record } = payload as IRecordUpdatePayload;
- return new RecordUpdateEvent(tableId, record, context);
+ const { tableId, record, oldField } = payload as IRecordUpdatePayload;
+ return new RecordUpdateEvent(tableId, record, oldField, context);
})
.otherwise(() => null);
}
diff --git a/apps/nestjs-backend/src/event-emitter/events/table/table.event.ts b/apps/nestjs-backend/src/event-emitter/events/table/table.event.ts
index 4c80a9f6a8..15ba393c89 100644
--- a/apps/nestjs-backend/src/event-emitter/events/table/table.event.ts
+++ b/apps/nestjs-backend/src/event-emitter/events/table/table.event.ts
@@ -11,7 +11,7 @@ export type IChangeTable = Record {
public readonly name = Events.TABLE_CREATE;
public readonly rawOpType = RawOpType.Create;
- constructor(baseId: string, table: ITableOp, context: IEventContext) {
- super({ baseId, table }, context);
+ constructor(payload: ITableCreatePayload, context: IEventContext) {
+ super(payload, context);
}
}
@@ -30,8 +30,8 @@ export class TableDeleteEvent extends OpEvent {
public readonly name = Events.TABLE_DELETE;
public readonly rawOpType = RawOpType.Del;
- constructor(baseId: string, tableId: string, context: IEventContext) {
- super({ baseId, tableId }, context);
+ constructor(payload: ITableDeletePayload, context: IEventContext) {
+ super(payload, context);
}
}
@@ -39,8 +39,8 @@ export class TableUpdateEvent extends OpEvent {
public readonly name = Events.TABLE_UPDATE;
public readonly rawOpType = RawOpType.Edit;
- constructor(baseId: string, table: IChangeTable, context: IEventContext) {
- super({ baseId, table }, context);
+ constructor(payload: ITableUpdatePayload, context: IEventContext) {
+ super(payload, context);
}
}
@@ -52,16 +52,13 @@ export class TableEventFactory {
) {
return match(name)
.with(Events.TABLE_CREATE, () => {
- const { baseId, table } = payload as ITableCreatePayload;
- return new TableCreateEvent(baseId, table, context);
+ return new TableCreateEvent(payload as ITableCreatePayload, context);
})
.with(Events.TABLE_DELETE, () => {
- const { baseId, tableId } = payload as ITableDeletePayload;
- return new TableDeleteEvent(baseId, tableId, context);
+ return new TableDeleteEvent(payload as ITableDeletePayload, context);
})
.with(Events.TABLE_UPDATE, () => {
- const { baseId, table } = payload as ITableUpdatePayload;
- return new TableUpdateEvent(baseId, table, context);
+ return new TableUpdateEvent(payload as ITableUpdatePayload, context);
})
.otherwise(() => null);
}
diff --git a/apps/nestjs-backend/src/event-emitter/events/user/user.event.ts b/apps/nestjs-backend/src/event-emitter/events/user/user.event.ts
new file mode 100644
index 0000000000..cf1b4a18ad
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/user/user.event.ts
@@ -0,0 +1,17 @@
+import { Events } from '../event.enum';
+
+export class UserSignUpEvent {
+ public readonly name = Events.USER_SIGNUP;
+
+ constructor(public readonly userId: string) {}
+}
+
+export class UserEmailChangeEvent {
+ public readonly name = Events.USER_EMAIL_CHANGE;
+
+ constructor(
+ public readonly userId: string,
+ public readonly oldEmail: string,
+ public readonly newEmail: string
+ ) {}
+}
diff --git a/apps/nestjs-backend/src/event-emitter/events/workflow/workflow.event.ts b/apps/nestjs-backend/src/event-emitter/events/workflow/workflow.event.ts
new file mode 100644
index 0000000000..a0e822ddeb
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/events/workflow/workflow.event.ts
@@ -0,0 +1,56 @@
+import { match } from 'ts-pattern';
+import type { IEventContext } from '../core-event';
+import { CoreEvent } from '../core-event';
+import { Events } from '../event.enum';
+
+interface IWorkflowVo {
+ id: string;
+ name: string;
+}
+
+type IWorkflowCreatePayload = { baseId: string; workflow: IWorkflowVo };
+type IWorkflowDeletePayload = { baseId: string; workflowId: string; permanent?: boolean };
+type IWorkflowUpdatePayload = IWorkflowCreatePayload;
+
+export class WorkflowCreateEvent extends CoreEvent {
+ public readonly name = Events.WORKFLOW_CREATE;
+
+ constructor(payload: IWorkflowCreatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class WorkflowDeleteEvent extends CoreEvent {
+ public readonly name = Events.WORKFLOW_DELETE;
+ constructor(payload: IWorkflowDeletePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class WorkflowUpdateEvent extends CoreEvent {
+ public readonly name = Events.WORKFLOW_UPDATE;
+
+ constructor(payload: IWorkflowUpdatePayload, context: IEventContext) {
+ super(payload, context);
+ }
+}
+
+export class WorkflowEventFactory {
+ static create(
+ name: string,
+ payload: IWorkflowCreatePayload | IWorkflowDeletePayload | IWorkflowUpdatePayload,
+ context: IEventContext
+ ) {
+ return match(name)
+ .with(Events.WORKFLOW_CREATE, () => {
+ return new WorkflowCreateEvent(payload as IWorkflowCreatePayload, context);
+ })
+ .with(Events.WORKFLOW_DELETE, () => {
+ return new WorkflowDeleteEvent(payload as IWorkflowDeletePayload, context);
+ })
+ .with(Events.WORKFLOW_UPDATE, () => {
+ return new WorkflowUpdateEvent(payload as IWorkflowUpdatePayload, context);
+ })
+ .otherwise(() => null);
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts b/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts
index 9d11626e6f..34a02acb8d 100644
--- a/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts
+++ b/apps/nestjs-backend/src/event-emitter/interceptor/event.Interceptor.ts
@@ -9,7 +9,15 @@ import { match, P } from 'ts-pattern';
import { EMIT_EVENT_NAME } from '../decorators/emit-controller-event.decorator';
import { EventEmitterService } from '../event-emitter.service';
import type { IEventContext } from '../events';
-import { Events, BaseEventFactory, SpaceEventFactory } from '../events';
+import {
+ Events,
+ BaseEventFactory,
+ SpaceEventFactory,
+ DashboardEventFactory,
+ AppEventFactory,
+ WorkflowEventFactory,
+} from '../events';
+import { BaseNodeEventFactory } from '../events/base/base-node.event';
@Injectable()
export class EventMiddleware implements NestInterceptor {
@@ -27,7 +35,9 @@ export class EventMiddleware implements NestInterceptor {
const interceptContext = this.interceptContext(req, data);
const event = this.createEvent(emitEventName, interceptContext);
- event && this.eventEmitterService.emitAsync(event.name, event);
+ event
+ ? this.eventEmitterService.emitAsync(event.name, event)
+ : this.eventEmitterService.emitAsync(emitEventName, interceptContext);
})
);
}
@@ -55,12 +65,61 @@ export class EventMiddleware implements NestInterceptor {
};
return match(eventName)
- .with(P.union(Events.BASE_CREATE, Events.BASE_DELETE, Events.BASE_UPDATE), () =>
+ .with(Events.BASE_DELETE, () =>
+ BaseEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.BASE_CREATE, Events.BASE_UPDATE, Events.BASE_PERMISSION_UPDATE), () =>
BaseEventFactory.create(eventName, { base: resolveData, ...reqParams }, eventContext)
)
- .with(P.union(Events.SPACE_CREATE, Events.SPACE_DELETE, Events.SPACE_UPDATE), () =>
+ .with(Events.SPACE_DELETE, () =>
+ SpaceEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.SPACE_CREATE, Events.SPACE_UPDATE), () =>
SpaceEventFactory.create(eventName, { space: resolveData, ...reqParams }, eventContext)
)
+ .with(Events.WORKFLOW_DELETE, () =>
+ WorkflowEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.WORKFLOW_CREATE, Events.WORKFLOW_UPDATE), () =>
+ WorkflowEventFactory.create(
+ eventName,
+ { baseId: reqParams.baseId, workflow: resolveData, ...reqParams },
+ eventContext
+ )
+ )
+ .with(Events.APP_DELETE, () =>
+ AppEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.APP_CREATE, Events.APP_UPDATE), () =>
+ AppEventFactory.create(
+ eventName,
+ { baseId: reqParams.baseId, app: resolveData, ...reqParams },
+ eventContext
+ )
+ )
+ .with(Events.DASHBOARD_DELETE, () =>
+ DashboardEventFactory.create(eventName, { ...resolveData, ...reqParams }, eventContext)
+ )
+ .with(P.union(Events.DASHBOARD_CREATE, Events.DASHBOARD_UPDATE), () =>
+ DashboardEventFactory.create(
+ eventName,
+ { baseId: reqParams.baseId, dashboard: resolveData, ...reqParams },
+ eventContext
+ )
+ )
+
+ .with(
+ P.union(Events.BASE_NODE_CREATE, Events.BASE_NODE_UPDATE, Events.BASE_NODE_DELETE),
+ () => {
+ const { baseId } = reqParams;
+ return BaseNodeEventFactory.create(
+ eventName,
+ { baseId, node: resolveData },
+ eventContext
+ );
+ }
+ )
+
.otherwise(() => null);
}
}
diff --git a/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts
index 32cf84bfa0..8030d2b54d 100644
--- a/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts
+++ b/apps/nestjs-backend/src/event-emitter/listeners/action-trigger.listener.ts
@@ -1,60 +1,100 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
-import type { IActionTriggerBuffer, IGridColumn } from '@teable/core';
+import type { ITableActionKey, IGridColumn, IViewActionKey } from '@teable/core';
import { getActionTriggerChannel, OpName } from '@teable/core';
import { isEmpty } from 'lodash';
+import { ClsService } from 'nestjs-cls';
import { match } from 'ts-pattern';
+import { getV2CreateTableLegacyEventsFlag } from '../../features/v2/v2-create-table-compat.constants';
import { ShareDbService } from '../../share-db/share-db.service';
+import type { IClsStore } from '../../types/cls';
import type {
RecordCreateEvent,
RecordDeleteEvent,
RecordUpdateEvent,
ViewUpdateEvent,
+ FieldUpdateEvent,
+ FieldCreateEvent,
+ FieldDeleteEvent,
} from '../events';
import { Events } from '../events';
type IViewEvent = ViewUpdateEvent;
type IRecordEvent = RecordCreateEvent | RecordDeleteEvent | RecordUpdateEvent;
-type IListenerEvent = IViewEvent | IRecordEvent;
+type IListenerEvent =
+ | IViewEvent
+ | IRecordEvent
+ | FieldUpdateEvent
+ | FieldCreateEvent
+ | FieldDeleteEvent;
+
+export interface IActionTriggerData {
+ actionKey: ITableActionKey | IViewActionKey;
+ payload?: Record;
+}
@Injectable()
export class ActionTriggerListener {
private readonly logger = new Logger(ActionTriggerListener.name);
- constructor(private readonly shareDbService: ShareDbService) {}
+ constructor(
+ private readonly shareDbService: ShareDbService,
+ private readonly cls: ClsService
+ ) {}
@OnEvent(Events.TABLE_VIEW_UPDATE, { async: true })
+ @OnEvent(Events.TABLE_FIELD_UPDATE, { async: true })
+ @OnEvent(Events.TABLE_FIELD_CREATE, { async: true })
+ @OnEvent(Events.TABLE_FIELD_DELETE, { async: true })
@OnEvent('table.record.*', { async: true })
private async listener(listenerEvent: IListenerEvent): Promise {
+ if (
+ getV2CreateTableLegacyEventsFlag(this.cls) &&
+ (this.isTableFieldCreateEvent(listenerEvent) || this.isTableRecordEvent(listenerEvent))
+ ) {
+ return;
+ }
+
// Handling table view update events
if (this.isTableViewUpdateEvent(listenerEvent)) {
await this.handleTableViewUpdate(listenerEvent as ViewUpdateEvent);
}
+ // Handling table field update events
+ if (this.isTableFieldUpdateEvent(listenerEvent)) {
+ await this.handleTableFieldUpdate(listenerEvent as FieldUpdateEvent);
+ }
+
+ // Handling table field create events
+ if (this.isTableFieldCreateEvent(listenerEvent)) {
+ await this.handleTableFieldCreate(listenerEvent as FieldCreateEvent);
+ }
+
+ // Handling table field delete events
+ if (this.isTableFieldDeleteEvent(listenerEvent)) {
+ await this.handleTableFieldDelete(listenerEvent as FieldDeleteEvent);
+ }
+
// Handling table record events (create, delete, update)
if (this.isTableRecordEvent(listenerEvent)) {
await this.handleTableRecordEvent(listenerEvent as IRecordEvent);
}
}
- // eslint-disable-next-line sonarjs/cognitive-complexity
private async handleTableViewUpdate(event: ViewUpdateEvent): Promise {
if (!this.isValidViewUpdateOperation(event)) {
return;
}
- const { tableId, view } = event.payload;
+ const { view } = event.payload;
const { id: viewId, filter, columnMeta, group } = view;
- const buffer: IActionTriggerBuffer = {
- applyViewFilter: filter ? [tableId, viewId] : undefined,
- applyViewGroup: group ? [tableId, viewId] : undefined,
- applyViewStatisticFunc: columnMeta ? [tableId, viewId] : undefined,
- showViewField: columnMeta ? [tableId, viewId] : undefined,
- };
+ const buffer: IViewActionKey[] = [];
+ filter && buffer.push('applyViewFilter');
+ group && buffer.push('applyViewGroup');
if (columnMeta != null) {
- Object.entries(columnMeta)?.forEach(([fieldId, { oldValue, newValue }]) => {
+ Object.entries(columnMeta)?.forEach(([_fieldId, { oldValue, newValue }]) => {
const oldColumn = oldValue as IGridColumn;
const newColumn = newValue as IGridColumn;
@@ -62,38 +102,56 @@ export class ActionTriggerListener {
const shouldApplyStatFunc = oldColumn?.statisticFunc !== newColumn?.statisticFunc;
if (shouldShow) {
- buffer.showViewField!.push(fieldId);
+ buffer.push('showViewField');
}
if (shouldApplyStatFunc) {
- buffer.applyViewStatisticFunc!.push(fieldId);
+ buffer.push('applyViewStatisticFunc');
}
});
-
- if (buffer.showViewField!.length <= 2) {
- delete buffer.showViewField;
- }
- if (buffer.applyViewStatisticFunc!.length <= 2) {
- delete buffer.applyViewStatisticFunc;
- }
}
if (!isEmpty(buffer)) {
- this.emitActionTrigger(tableId, buffer);
+ this.emitActionTrigger(
+ viewId,
+ buffer.map((actionKey) => ({ actionKey }))
+ );
}
}
+ private async handleTableFieldUpdate(event: FieldUpdateEvent): Promise {
+ if (!this.isValidFieldUpdateOperation(event)) {
+ return;
+ }
+
+ const { tableId } = event.payload;
+ return this.emitActionTrigger(tableId, [{ actionKey: 'setField', payload: event.payload }]);
+ }
+
+ private async handleTableFieldCreate(event: FieldCreateEvent): Promise {
+ const { tableId } = event.payload;
+ return this.emitActionTrigger(tableId, [{ actionKey: 'addField', payload: event.payload }]);
+ }
+
+ private async handleTableFieldDelete(event: FieldDeleteEvent): Promise {
+ const { tableId } = event.payload;
+ return this.emitActionTrigger(tableId, [{ actionKey: 'deleteField', payload: event.payload }]);
+ }
+
private async handleTableRecordEvent(event: IRecordEvent): Promise {
const { tableId } = event.payload;
const buffer = match(event)
- .returnType()
- .with({ name: Events.TABLE_RECORD_CREATE }, () => ({ addRecord: [tableId] }))
- .with({ name: Events.TABLE_RECORD_UPDATE }, () => ({ setRecord: [tableId] }))
- .with({ name: Events.TABLE_RECORD_DELETE }, () => ({ deleteRecord: [tableId] }))
- .otherwise(() => ({}));
+ .returnType()
+ .with({ name: Events.TABLE_RECORD_CREATE }, () => ['addRecord'])
+ .with({ name: Events.TABLE_RECORD_UPDATE }, () => ['setRecord'])
+ .with({ name: Events.TABLE_RECORD_DELETE }, () => ['deleteRecord'])
+ .otherwise(() => []);
if (!isEmpty(buffer)) {
- this.emitActionTrigger(tableId, buffer);
+ this.emitActionTrigger(
+ tableId,
+ buffer.map((actionKey) => ({ actionKey }))
+ );
}
}
@@ -101,12 +159,30 @@ export class ActionTriggerListener {
return Events.TABLE_VIEW_UPDATE === event.name;
}
+ private isTableFieldUpdateEvent(event: IListenerEvent): boolean {
+ return Events.TABLE_FIELD_UPDATE === event.name;
+ }
+
+ private isTableFieldCreateEvent(event: IListenerEvent): boolean {
+ return Events.TABLE_FIELD_CREATE === event.name;
+ }
+
+ private isTableFieldDeleteEvent(event: IListenerEvent): boolean {
+ return Events.TABLE_FIELD_DELETE === event.name;
+ }
+
private isValidViewUpdateOperation(event: ViewUpdateEvent): boolean | undefined {
const propertyKeys = ['filter', 'group'];
const { name, propertyKey } = event.context.opMeta || {};
return name === OpName.UpdateViewColumnMeta || propertyKeys.includes(propertyKey as string);
}
+ private isValidFieldUpdateOperation(event: FieldUpdateEvent): boolean | undefined {
+ const propertyKeys = ['options', 'dbFieldType'];
+ const { propertyKey } = event.context.opMeta || {};
+ return propertyKeys.includes(propertyKey as string);
+ }
+
private isTableRecordEvent(event: IListenerEvent): boolean {
const recordEvents = [
Events.TABLE_RECORD_CREATE,
@@ -116,12 +192,12 @@ export class ActionTriggerListener {
return recordEvents.includes(event.name);
}
- private emitActionTrigger(tableId: string, data: IActionTriggerBuffer) {
- const channel = getActionTriggerChannel(tableId);
+ private emitActionTrigger(tableIdOrViewId: string, data: IActionTriggerData[]) {
+ const channel = getActionTriggerChannel(tableIdOrViewId);
const presence = this.shareDbService.connect().getPresence(channel);
- const localPresence = presence.create(tableId);
- localPresence.submit({ ...data, t: new Date().getTime() }, (error) => {
+ const localPresence = presence.create(tableIdOrViewId);
+ localPresence.submit(data, (error) => {
error && this.logger.error(error);
});
}
diff --git a/apps/nestjs-backend/src/event-emitter/listeners/base-permission-update.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/base-permission-update.listener.ts
new file mode 100644
index 0000000000..21f3324d73
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/listeners/base-permission-update.listener.ts
@@ -0,0 +1,51 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { OnEvent } from '@nestjs/event-emitter';
+import { getBasePermissionUpdateChannel } from '@teable/core';
+import { PrismaService } from '@teable/db-main-prisma';
+import { ShareDbService } from '../../share-db/share-db.service';
+import { EventEmitterService } from '../event-emitter.service';
+import { Events, BasePermissionUpdateEvent } from '../events';
+import { CollaboratorUpdateEvent } from '../events/space/collaborator.event';
+
+@Injectable()
+export class BasePermissionUpdateListener {
+ private readonly logger = new Logger(BasePermissionUpdateListener.name);
+
+ constructor(
+ private readonly shareDbService: ShareDbService,
+ private readonly prismaService: PrismaService,
+ private readonly eventEmitterService: EventEmitterService
+ ) {}
+
+ @OnEvent(Events.BASE_PERMISSION_UPDATE, { async: true })
+ async basePermissionUpdateListener(listenerEvent: BasePermissionUpdateEvent) {
+ const {
+ payload: { baseId },
+ context: { user },
+ } = listenerEvent;
+ const space = await this.prismaService.base.findUnique({
+ where: {
+ id: baseId,
+ },
+ select: {
+ spaceId: true,
+ },
+ });
+
+ if (space?.spaceId) {
+ this.eventEmitterService.emitAsync(
+ Events.COLLABORATOR_UPDATE,
+ new CollaboratorUpdateEvent(space.spaceId)
+ );
+ }
+
+ const channel = getBasePermissionUpdateChannel(baseId);
+ const presence = this.shareDbService.connect().getPresence(channel);
+ const localPresence = presence.create();
+
+ // Include the operator user ID in the message to allow filtering on the client side
+ localPresence.submit(user?.id, (error) => {
+ error && this.logger.error(error);
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts
index 14c957a7ae..2fddad997e 100644
--- a/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts
+++ b/apps/nestjs-backend/src/event-emitter/listeners/collaborator-notification.listener.ts
@@ -4,9 +4,10 @@ import type { IRecord, IUserCellValue } from '@teable/core';
import { FieldType } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import { Knex } from 'knex';
-import { has, intersection, isEmpty, keyBy } from 'lodash';
+import { has, intersection, isEmpty, keyBy, uniq } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import { NotificationService } from '../../features/notification/notification.service';
+import { RecordService } from '../../features/record/record.service';
import type { IChangeRecord, IChangeValue, RecordCreateEvent, RecordUpdateEvent } from '../events';
import { Events } from '../events';
@@ -20,6 +21,9 @@ type IUserField = {
fieldOptions: string;
};
+// Maximum number of record titles to fetch for notification display
+const maxRecordTitles = 10;
+
@Injectable()
export class CollaboratorNotificationListener {
private readonly logger = new Logger(CollaboratorNotificationListener.name);
@@ -27,6 +31,7 @@ export class CollaboratorNotificationListener {
constructor(
private readonly prismaService: PrismaService,
private readonly notificationService: NotificationService,
+ private readonly recordService: RecordService,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
) {}
@@ -78,9 +83,20 @@ export class CollaboratorNotificationListener {
const notificationData = this.extractNotificationData(recordSets, userFieldIds);
+ // Collect record IDs that need titles (limited to maxRecordTitles per user)
+ const recordIdsNeedingTitles = uniq(
+ Object.values(notificationData).flatMap((data) => data.recordIds.slice(0, maxRecordTitles))
+ );
+ const recordTitles =
+ recordIdsNeedingTitles.length > 0
+ ? await this.recordService.getRecordsHeadWithIds(tableId, recordIdsNeedingTitles)
+ : [];
+ const recordTitlesMap = keyBy(recordTitles, 'id');
+
for (const userId in notificationData) {
const { fieldId, recordIds } = notificationData[userId];
const field = userFields[fieldId];
+ const recordIdsForTitles = recordIds.slice(0, maxRecordTitles);
await this.notificationService.sendCollaboratorNotify({
fromUserId: user?.id || '',
@@ -91,6 +107,7 @@ export class CollaboratorNotificationListener {
tableName: field.tableName,
fieldName: field.fieldName,
recordIds: recordIds,
+ recordTitles: recordIdsForTitles.map((id) => recordTitlesMap[id]).filter(Boolean),
},
});
}
diff --git a/apps/nestjs-backend/src/event-emitter/listeners/pin.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/pin.listener.ts
new file mode 100644
index 0000000000..950c5def78
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/listeners/pin.listener.ts
@@ -0,0 +1,34 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { OnEvent } from '@nestjs/event-emitter';
+import { PrismaService } from '@teable/db-main-prisma';
+import type { SpaceDeleteEvent, BaseDeleteEvent } from '../events';
+import { Events } from '../events';
+
+@Injectable()
+export class PinListener {
+ private readonly logger = new Logger(PinListener.name);
+
+ constructor(private readonly prismaService: PrismaService) {}
+
+ @OnEvent(Events.BASE_DELETE, { async: true })
+ @OnEvent(Events.SPACE_DELETE, { async: true })
+ async spaceAndBaseDelete(listenerEvent: SpaceDeleteEvent | BaseDeleteEvent) {
+ let id: string = '';
+ if (listenerEvent.name === Events.SPACE_DELETE) {
+ id = listenerEvent.payload.spaceId;
+ }
+ if (listenerEvent.name === Events.BASE_DELETE) {
+ id = listenerEvent.payload.baseId;
+ }
+
+ if (!id) {
+ return;
+ }
+
+ await this.prismaService.pinResource.deleteMany({
+ where: {
+ resourceId: id,
+ },
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts
new file mode 100644
index 0000000000..bc812a22d4
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/listeners/record-history.listener.ts
@@ -0,0 +1,170 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { Injectable } from '@nestjs/common';
+import { OnEvent } from '@nestjs/event-emitter';
+import type { ISelectFieldOptions } from '@teable/core';
+import { FieldType, generateRecordHistoryId } from '@teable/core';
+import { PrismaService } from '@teable/db-main-prisma';
+import type { Field } from '@teable/db-main-prisma';
+import { Knex } from 'knex';
+import { isEqual, isObject, isString } from 'lodash';
+import { InjectModel } from 'nest-knexjs';
+import { BaseConfig, IBaseConfig } from '../../configs/base.config';
+import { DataLoaderService } from '../../features/data-loader/data-loader.service';
+import { rawField2FieldObj } from '../../features/field/model/factory';
+import { EventEmitterService } from '../event-emitter.service';
+import { Events, RecordUpdateEvent } from '../events';
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const SELECT_FIELD_TYPE_SET = new Set([FieldType.SingleSelect, FieldType.MultipleSelect]);
+
+@Injectable()
+export class RecordHistoryListener {
+ constructor(
+ private readonly prismaService: PrismaService,
+ private readonly eventEmitterService: EventEmitterService,
+ @BaseConfig() private readonly baseConfig: IBaseConfig,
+ @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
+ private readonly dataLoaderService: DataLoaderService
+ ) {}
+
+ @OnEvent(Events.TABLE_RECORD_UPDATE, { async: true })
+ async recordUpdateListener(event: RecordUpdateEvent) {
+ if (this.baseConfig.recordHistoryDisabled) {
+ return;
+ }
+
+ const { payload, context } = event;
+ const { user } = context;
+ const { tableId, oldField: _oldField } = payload;
+ const userId = user?.id;
+ const payloadRecord = payload.record;
+ const records = !Array.isArray(payloadRecord) ? [payloadRecord] : payloadRecord;
+
+ const fieldIdSet = new Set();
+
+ records.forEach((record) => {
+ const { fields } = record;
+
+ Object.keys(fields).forEach((fieldId) => {
+ fieldIdSet.add(fieldId);
+ });
+ });
+
+ const fieldIds = Array.from(fieldIdSet);
+
+ const fields = await this.dataLoaderService.field.load(tableId, {
+ id: fieldIds,
+ });
+
+ const fieldMap = new Map(fields.map((field) => [field.id, rawField2FieldObj(field)]));
+
+ const batchSize = 5000;
+ const totalCount = records.length;
+
+ for (let i = 0; i < totalCount; i += batchSize) {
+ const batch = records.slice(i, i + batchSize);
+ const recordHistoryList: {
+ id: string;
+ table_id: string;
+ record_id: string;
+ field_id: string;
+ before: string;
+ after: string;
+ created_by: string;
+ }[] = [];
+
+ batch.forEach((record) => {
+ const { id: recordId, fields } = record;
+ Object.entries(fields).forEach(([fieldId, changeValue]) => {
+ const field = fieldMap.get(fieldId);
+
+ if (!field || !changeValue || !isObject(changeValue)) {
+ return null;
+ }
+
+ if (!('oldValue' in changeValue) || !('newValue' in changeValue)) {
+ return null;
+ }
+
+ const oldField = _oldField ?? field;
+ const { type, name, cellValueType, isComputed } = field;
+ const { oldValue, newValue } = changeValue;
+
+ // Skip no-op changes to avoid duplicate history entries
+ if (isEqual(oldValue, newValue)) {
+ return null;
+ }
+
+ if (oldField.isComputed && isComputed) {
+ return null;
+ }
+
+ recordHistoryList.push({
+ id: generateRecordHistoryId(),
+ table_id: tableId,
+ record_id: recordId,
+ field_id: fieldId,
+ before: JSON.stringify({
+ meta: {
+ type: oldField.type,
+ name: oldField.name,
+ options: this.minimizeFieldOptions(oldValue, oldField),
+ cellValueType: oldField.cellValueType,
+ },
+ data: oldValue,
+ }),
+ after: JSON.stringify({
+ meta: {
+ type,
+ name,
+ options: this.minimizeFieldOptions(newValue, field),
+ cellValueType,
+ },
+ data: newValue,
+ }),
+ created_by: userId as string,
+ });
+ });
+ });
+
+ if (recordHistoryList.length) {
+ const query = this.knex.insert(recordHistoryList).into('record_history').toQuery();
+
+ await this.prismaService.$executeRawUnsafe(query);
+ }
+ }
+
+ this.eventEmitterService.emit(Events.RECORD_HISTORY_CREATE, {
+ recordIds: records.map((record) => record.id),
+ });
+ }
+
+ private minimizeFieldOptions(
+ value: unknown,
+ field: Pick & {
+ options: Record | null;
+ }
+ ) {
+ const { type, options: _options } = field;
+
+ if (SELECT_FIELD_TYPE_SET.has(type as FieldType)) {
+ const options = _options as ISelectFieldOptions;
+ const { choices } = options;
+
+ if (value == null) {
+ return { ...options, choices: [] };
+ }
+
+ if (isString(value)) {
+ return { ...options, choices: choices.filter(({ name }) => name === value) };
+ }
+
+ if (Array.isArray(value)) {
+ const valueSet = new Set(value);
+ return { ...options, choices: choices.filter(({ name }) => valueSet.has(name)) };
+ }
+ }
+
+ return _options;
+ }
+}
diff --git a/apps/nestjs-backend/src/event-emitter/listeners/trash.listener.ts b/apps/nestjs-backend/src/event-emitter/listeners/trash.listener.ts
new file mode 100644
index 0000000000..a6072f7596
--- /dev/null
+++ b/apps/nestjs-backend/src/event-emitter/listeners/trash.listener.ts
@@ -0,0 +1,111 @@
+import { Injectable } from '@nestjs/common';
+import { OnEvent } from '@nestjs/event-emitter';
+import { PrismaService } from '@teable/db-main-prisma';
+import { ResourceType } from '@teable/openapi';
+import type {
+ SpaceDeleteEvent,
+ BaseDeleteEvent,
+ TableDeleteEvent,
+ AppDeleteEvent,
+ WorkflowDeleteEvent,
+} from '../events';
+import { Events } from '../events';
+
+@Injectable()
+export class TrashListener {
+ constructor(private readonly prismaService: PrismaService) {}
+
+ @OnEvent(Events.SPACE_DELETE, { async: true })
+ @OnEvent(Events.BASE_DELETE, { async: true })
+ @OnEvent(Events.TABLE_DELETE, { async: true })
+ @OnEvent(Events.APP_DELETE, { async: true })
+ @OnEvent(Events.WORKFLOW_DELETE, { async: true })
+ async onEvent(
+ event:
+ | SpaceDeleteEvent
+ | BaseDeleteEvent
+ | TableDeleteEvent
+ | AppDeleteEvent
+ | WorkflowDeleteEvent
+ ) {
+ const { name, payload } = event;
+ const { user } = event.context;
+ let resourceId: string;
+ let resourceType: ResourceType;
+ let deletedTime: Date | undefined | null;
+ let parentId: string | undefined;
+
+ if ('permanent' in payload && payload.permanent) {
+ return;
+ }
+
+ switch (name) {
+ case Events.SPACE_DELETE: {
+ resourceId = payload.spaceId;
+ resourceType = ResourceType.Space;
+ const space = await this.prismaService.space.findUnique({
+ where: { id: resourceId },
+ select: { id: true, deletedTime: true },
+ });
+ deletedTime = space?.deletedTime;
+ break;
+ }
+ case Events.BASE_DELETE: {
+ resourceId = payload.baseId;
+ resourceType = ResourceType.Base;
+ const base = await this.prismaService.base.findUnique({
+ where: { id: resourceId },
+ select: { id: true, spaceId: true, deletedTime: true },
+ });
+ deletedTime = base?.deletedTime;
+ parentId = base?.spaceId;
+ break;
+ }
+ case Events.TABLE_DELETE: {
+ resourceId = payload.tableId;
+ resourceType = ResourceType.Table;
+ const table = await this.prismaService.tableMeta.findUnique({
+ where: { id: resourceId },
+ select: { id: true, baseId: true, deletedTime: true },
+ });
+ deletedTime = table?.deletedTime;
+ parentId = table?.baseId;
+ break;
+ }
+ case Events.APP_DELETE: {
+ resourceId = payload.appId;
+ resourceType = ResourceType.App;
+ const app = await this.prismaService.app.findUnique({
+ where: { id: resourceId },
+ select: { id: true, baseId: true, deletedTime: true },
+ });
+ deletedTime = app?.deletedTime;
+ parentId = app?.baseId;
+ break;
+ }
+ case Events.WORKFLOW_DELETE: {
+ resourceId = payload.workflowId;
+ resourceType = ResourceType.Workflow;
+ const workflow = await this.prismaService.workflow.findUnique({
+ where: { id: resourceId },
+ select: { id: true, baseId: true, deletedTime: true },
+ });
+ deletedTime = workflow?.deletedTime;
+ parentId = workflow?.baseId;
+ break;
+ }
+ }
+
+ if (!deletedTime) return;
+
+ await this.prismaService.trash.create({
+ data: {
+ resourceId,
+ resourceType,
+ parentId,
+ deletedTime,
+ deletedBy: user?.id as string,
+ },
+ });
+ }
+}
diff --git a/apps/nestjs-backend/src/features/access-token/access-token.controller.ts b/apps/nestjs-backend/src/features/access-token/access-token.controller.ts
index c34f07f8ee..c590e1aba3 100644
--- a/apps/nestjs-backend/src/features/access-token/access-token.controller.ts
+++ b/apps/nestjs-backend/src/features/access-token/access-token.controller.ts
@@ -15,6 +15,8 @@ import {
updateAccessTokenRoSchema,
RefreshAccessTokenRo,
} from '@teable/openapi';
+import { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';
+import { Events } from '../../event-emitter/events';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { AccessTokenService } from './access-token.service';
@@ -23,6 +25,7 @@ export class AccessTokenController {
constructor(private readonly accessTokenService: AccessTokenService) {}
@Post()
+ @EmitControllerEvent(Events.ACCESS_TOKEN_CREATE)
async createAccessToken(
@Body(new ZodValidationPipe(createAccessTokenRoSchema)) body: CreateAccessTokenRo
): Promise {
@@ -38,6 +41,7 @@ export class AccessTokenController {
}
@Delete(':accessTokenId')
+ @EmitControllerEvent(Events.ACCESS_TOKEN_DELETE)
async deleteAccessToken(@Param('accessTokenId') accessTokenId: string) {
return await this.accessTokenService.deleteAccessToken(accessTokenId);
}
diff --git a/apps/nestjs-backend/src/features/access-token/access-token.encryptor.ts b/apps/nestjs-backend/src/features/access-token/access-token.encryptor.ts
index e9f5d6205e..0491726711 100644
--- a/apps/nestjs-backend/src/features/access-token/access-token.encryptor.ts
+++ b/apps/nestjs-backend/src/features/access-token/access-token.encryptor.ts
@@ -24,14 +24,19 @@ export const getAccessToken = (accessTokenId: string, sign: string) => {
};
export const splitAccessToken = (accessToken: string) => {
- const [prefix, accessTokenId, encryptedSign] = accessToken.split('_');
+ const [prefix = '', accessTokenId = '', encryptedSign = ''] = accessToken.split('_');
if (!accessTokenId) {
return null;
}
if (prefix !== authConfig().accessToken.prefix) {
return null;
}
- const { sign } = getAccessTokenEncryptor().decrypt(encryptedSign);
+ let sign: string | null = null;
+ try {
+ sign = getAccessTokenEncryptor().decrypt(encryptedSign).sign;
+ } catch (error) {
+ return null;
+ }
if (!sign) {
return null;
}
diff --git a/apps/nestjs-backend/src/features/access-token/access-token.service.spec.ts b/apps/nestjs-backend/src/features/access-token/access-token.service.spec.ts
index 7fe5f9b44c..b2de1034a3 100644
--- a/apps/nestjs-backend/src/features/access-token/access-token.service.spec.ts
+++ b/apps/nestjs-backend/src/features/access-token/access-token.service.spec.ts
@@ -5,12 +5,14 @@ import { Test } from '@nestjs/testing';
import { PrismaService } from '@teable/db-main-prisma';
import { mockDeep, mockReset } from 'vitest-mock-extended';
import { GlobalModule } from '../../global/global.module';
+import { AccessTokenModel } from '../model/access-token';
import { AccessTokenModule } from './access-token.module';
import { AccessTokenService } from './access-token.service';
describe('AccessTokenService', () => {
let accessTokenService: AccessTokenService;
const prismaService = mockDeep();
+ const accessTokenModel = mockDeep();
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -18,6 +20,8 @@ describe('AccessTokenService', () => {
})
.overrideProvider(PrismaService)
.useValue(prismaService)
+ .overrideProvider(AccessTokenModel)
+ .useValue(accessTokenModel)
.compile();
accessTokenService = module.get(AccessTokenService);
@@ -47,7 +51,7 @@ describe('AccessTokenService', () => {
const sign = 'SIGN';
const expiredTime = new Date(Date.now() + 2000); // Expires in 2 seconds
// Mock PrismaService response
- prismaService.accessToken.findUniqueOrThrow.mockResolvedValue({
+ accessTokenModel.getAccessTokenRawById.mockResolvedValue({
userId: 'user123',
id: accessTokenId,
sign,
@@ -74,7 +78,7 @@ describe('AccessTokenService', () => {
const sign = 'INVALID_SIGN';
// Mock PrismaService response
- prismaService.accessToken.findUniqueOrThrow.mockResolvedValue({
+ accessTokenModel.getAccessTokenRawById.mockResolvedValue({
userId: 'user123',
id: accessTokenId,
sign: 'VALID_SIGN',
@@ -97,7 +101,7 @@ describe('AccessTokenService', () => {
const expiredTime = new Date(Date.now() - 1500); // Expired 1 second ago
// Mock PrismaService response
- prismaService.accessToken.findUniqueOrThrow.mockResolvedValue({
+ accessTokenModel.getAccessTokenRawById.mockResolvedValue({
userId: 'user123',
id: accessTokenId,
sign,
diff --git a/apps/nestjs-backend/src/features/access-token/access-token.service.ts b/apps/nestjs-backend/src/features/access-token/access-token.service.ts
index 73ceeea7d3..a8461cef62 100644
--- a/apps/nestjs-backend/src/features/access-token/access-token.service.ts
+++ b/apps/nestjs-backend/src/features/access-token/access-token.service.ts
@@ -1,5 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
-import type { AllActions } from '@teable/core';
+import type { Action } from '@teable/core';
import { generateAccessTokenId, getRandomString } from '@teable/core';
import { PrismaService } from '@teable/db-main-prisma';
import type {
@@ -8,14 +8,19 @@ import type {
UpdateAccessTokenRo,
} from '@teable/openapi';
import { ClsService } from 'nestjs-cls';
+import { PerformanceCacheService } from '../../performance-cache';
+import { generateAccessTokenCacheKey } from '../../performance-cache/generate-keys';
import type { IClsStore } from '../../types/cls';
+import { AccessTokenModel } from '../model/access-token';
import { getAccessToken } from './access-token.encryptor';
@Injectable()
export class AccessTokenService {
constructor(
private readonly prismaService: PrismaService,
- private readonly cls: ClsService
+ private readonly cls: ClsService,
+ private readonly accessTokenModel: AccessTokenModel,
+ private readonly performanceCacheService: PerformanceCacheService
) {}
private transformAccessTokenEntity<
@@ -27,42 +32,49 @@ export class AccessTokenService {
createdTime?: Date;
lastUsedTime?: Date | null;
expiredTime?: Date;
+ hasFullAccess?: boolean | null;
},
>(accessTokenEntity: T) {
- const { scopes, spaceIds, baseIds, createdTime, lastUsedTime, expiredTime, description } =
- accessTokenEntity;
+ const {
+ scopes,
+ spaceIds,
+ baseIds,
+ createdTime,
+ lastUsedTime,
+ expiredTime,
+ description,
+ hasFullAccess,
+ } = accessTokenEntity;
return {
...accessTokenEntity,
description: description || undefined,
- scopes: JSON.parse(scopes) as AllActions[],
+ scopes: JSON.parse(scopes) as Action[],
spaceIds: spaceIds ? (JSON.parse(spaceIds) as string[]) : undefined,
baseIds: baseIds ? (JSON.parse(baseIds) as string[]) : undefined,
createdTime: createdTime?.toISOString(),
lastUsedTime: lastUsedTime?.toISOString(),
expiredTime: expiredTime?.toISOString(),
+ hasFullAccess: hasFullAccess ?? undefined,
};
}
async validate(splitAccessTokenObj: { accessTokenId: string; sign: string }) {
const { accessTokenId, sign } = splitAccessTokenObj;
-
- const accessTokenEntity = await this.prismaService.txClient().accessToken.findUniqueOrThrow({
- where: { id: accessTokenId },
- select: {
- userId: true,
- id: true,
- sign: true,
- expiredTime: true,
- },
- });
+ const accessTokenEntity = await this.accessTokenModel.getAccessTokenRawById(accessTokenId);
+ if (!accessTokenEntity) {
+ throw new UnauthorizedException('token not found');
+ }
if (sign !== accessTokenEntity.sign) {
throw new UnauthorizedException('sign error');
}
// expiredTime 1ms tolerance
- if (accessTokenEntity.expiredTime.getTime() < Date.now() + 1000) {
+ if (
+ accessTokenEntity.expiredTime &&
+ new Date(accessTokenEntity.expiredTime).getTime() < Date.now() + 1000
+ ) {
throw new UnauthorizedException('token expired');
}
- await this.prismaService.txClient().accessToken.update({
+ await this.prismaService.accessToken.update({
where: { id: accessTokenId },
data: { lastUsedTime: new Date().toISOString() },
});
@@ -76,7 +88,7 @@ export class AccessTokenService {
async listAccessToken() {
const userId = this.cls.get('user.id');
const list = await this.prismaService.accessToken.findMany({
- where: { userId },
+ where: { userId, clientId: null },
select: {
id: true,
name: true,
@@ -84,6 +96,7 @@ export class AccessTokenService {
scopes: true,
spaceIds: true,
baseIds: true,
+ hasFullAccess: true,
createdTime: true,
expiredTime: true,
lastUsedTime: true,
@@ -93,12 +106,15 @@ export class AccessTokenService {
return list.map(this.transformAccessTokenEntity);
}
- async createAccessToken(createAccessToken: CreateAccessTokenRo) {
- const userId = this.cls.get('user.id');
- const { name, description, scopes, spaceIds, baseIds, expiredTime } = createAccessToken;
+ async createAccessToken(
+ createAccessToken: CreateAccessTokenRo & { clientId?: string; userId?: string }
+ ) {
+ const userId = createAccessToken.userId ?? this.cls.get('user.id')!;
+ const { name, description, scopes, spaceIds, baseIds, expiredTime, clientId, hasFullAccess } =
+ createAccessToken;
const id = generateAccessTokenId();
const sign = getRandomString(16);
- const accessTokenEntity = await this.prismaService.accessToken.create({
+ const accessTokenEntity = await this.prismaService.txClient().accessToken.create({
data: {
id,
name,
@@ -108,7 +124,9 @@ export class AccessTokenService {
baseIds: baseIds === null ? null : JSON.stringify(baseIds),
userId,
sign,
+ clientId,
expiredTime: new Date(expiredTime).toISOString(),
+ hasFullAccess,
},
select: {
id: true,
@@ -120,6 +138,7 @@ export class AccessTokenService {
expiredTime: true,
createdTime: true,
lastUsedTime: true,
+ hasFullAccess: true,
},
});
return {
@@ -157,6 +176,7 @@ export class AccessTokenService {
lastUsedTime: true,
},
});
+ await this.performanceCacheService.del(generateAccessTokenCacheKey(id));
return {
...this.transformAccessTokenEntity(accessTokenEntity),
token: getAccessToken(id, sign),
@@ -165,7 +185,7 @@ export class AccessTokenService {
async updateAccessToken(id: string, updateAccessToken: UpdateAccessTokenRo) {
const userId = this.cls.get('user.id');
- const { name, description, scopes, spaceIds, baseIds } = updateAccessToken;
+ const { name, description, scopes, spaceIds, baseIds, hasFullAccess } = updateAccessToken;
const accessTokenEntity = await this.prismaService.accessToken.update({
where: { id, userId },
data: {
@@ -174,6 +194,7 @@ export class AccessTokenService {
scopes: JSON.stringify(scopes),
spaceIds: spaceIds === null ? null : JSON.stringify(spaceIds),
baseIds: baseIds === null ? null : JSON.stringify(baseIds),
+ hasFullAccess,
},
select: {
id: true,
@@ -182,8 +203,10 @@ export class AccessTokenService {
scopes: true,
spaceIds: true,
baseIds: true,
+ hasFullAccess: true,
},
});
+ await this.performanceCacheService.del(generateAccessTokenCacheKey(id));
return this.transformAccessTokenEntity(accessTokenEntity);
}
@@ -201,8 +224,32 @@ export class AccessTokenService {
createdTime: true,
expiredTime: true,
lastUsedTime: true,
+ hasFullAccess: true,
},
});
- return this.transformAccessTokenEntity(item);
+ const res = this.transformAccessTokenEntity(item);
+ // filter deleted spaceIds and baseIds
+ const { spaceIds, baseIds } = res;
+ let filteredSpaceIds: string[] | undefined;
+ let filteredBaseIds: string[] | undefined;
+ if (spaceIds) {
+ const spaces = await this.prismaService.space.findMany({
+ where: { id: { in: spaceIds }, deletedTime: null },
+ select: { id: true },
+ });
+ filteredSpaceIds = spaces.map((space) => space.id);
+ }
+ if (baseIds) {
+ const bases = await this.prismaService.base.findMany({
+ where: { id: { in: baseIds }, deletedTime: null },
+ select: { id: true },
+ });
+ filteredBaseIds = bases.map((base) => base.id);
+ }
+ return {
+ ...res,
+ spaceIds: filteredSpaceIds,
+ baseIds: filteredBaseIds,
+ };
}
}
diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.module.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.module.ts
index 0e73a67635..f4847b581c 100644
--- a/apps/nestjs-backend/src/features/aggregation/aggregation.module.ts
+++ b/apps/nestjs-backend/src/features/aggregation/aggregation.module.ts
@@ -1,11 +1,25 @@
import { Module } from '@nestjs/common';
import { DbProvider } from '../../db-provider/db.provider';
+import { RecordQueryBuilderModule } from '../record/query-builder';
+import { RecordPermissionService } from '../record/record-permission.service';
import { RecordModule } from '../record/record.module';
+import { TableIndexService } from '../table/table-index.service';
import { AggregationService } from './aggregation.service';
+import { AGGREGATION_SERVICE_SYMBOL } from './aggregation.service.symbol';
@Module({
- imports: [RecordModule],
- providers: [DbProvider, AggregationService],
- exports: [AggregationService],
+ imports: [RecordModule, RecordQueryBuilderModule],
+ providers: [
+ DbProvider,
+ TableIndexService,
+ RecordPermissionService,
+ AggregationService,
+ {
+ provide: AGGREGATION_SERVICE_SYMBOL,
+ useClass: AggregationService,
+ // useClass: AggregationService,
+ },
+ ],
+ exports: [AGGREGATION_SERVICE_SYMBOL, AggregationService],
})
export class AggregationModule {}
diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts
new file mode 100644
index 0000000000..8729a5251a
--- /dev/null
+++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.interface.ts
@@ -0,0 +1,159 @@
+import type { IFilter, IGroup, StatisticsFunc } from '@teable/core';
+import type {
+ IAggregationField,
+ IQueryBaseRo,
+ IRawAggregationValue,
+ IRawAggregations,
+ IRawRowCountValue,
+ IGroupPointsRo,
+ IGroupPoint,
+ ICalendarDailyCollectionRo,
+ ICalendarDailyCollectionVo,
+ ISearchIndexByQueryRo,
+ ISearchCountRo,
+ IRecordIndexRo,
+ IRecordIndexVo,
+} from '@teable/openapi';
+import type { IFieldInstance } from '../field/model/factory';
+
+/**
+ * Interface for aggregation service operations
+ * This interface defines the public API for aggregation-related functionality
+ */
+export interface IAggregationService {
+ /**
+ * Perform aggregation operations on table data
+ * @param params - Parameters for aggregation including tableId, field IDs, view settings, and search
+ * @returns Promise - The aggregation results
+ */
+ performAggregation(params: {
+ tableId: string;
+ withFieldIds?: string[];
+ withView?: IWithView;
+ search?: [string, string?, boolean?];
+ useQueryModel?: boolean;
+ }): Promise;
+
+ /**
+ * Perform grouped aggregation operations
+ * @param params - Parameters for grouped aggregation
+ * @returns Promise - The grouped aggregation results
+ */
+ performGroupedAggregation(params: {
+ aggregations: IRawAggregations;
+ statisticFields: IAggregationField[] | undefined;
+ tableId: string;
+ filter?: IFilter;
+ search?: [string, string?, boolean?];
+ groupBy?: IGroup;
+ dbTableName: string;
+ fieldInstanceMap: Record;
+ withView?: IWithView;
+ }): Promise;
+
+ /**
+ * Get row count for a table with optional filtering
+ * @param tableId - The table ID
+ * @param queryRo - Query parameters for filtering
+ * @returns Promise - The row count result
+ */
+ performRowCount(tableId: string, queryRo: IQueryBaseRo): Promise;
+
+ /**
+ * Get field data for a table
+ * @param tableId - The table ID
+ * @param fieldIds - Optional array of field IDs to filter
+ * @param withName - Whether to include field names in the mapping
+ * @returns Promise with field instances and field instance map
+ */
+ getFieldsData(
+ tableId: string,
+ fieldIds?: string[],
+ withName?: boolean
+ ): Promise<{
+ fieldInstances: IFieldInstance[];
+ fieldInstanceMap: Record;
+ }>;
+
+ /**
+ * Get group points for a table
+ * @param tableId - The table ID
+ * @param query - Optional query parameters
+ * @returns Promise with group points data
+ */
+ getGroupPoints(
+ tableId: string,
+ query?: IGroupPointsRo,
+ useQueryModel?: boolean
+ ): Promise;
+
+ /**
+ * Get search count for a table
+ * @param tableId - The table ID
+ * @param queryRo - Search query parameters
+ * @param projection - Optional field projection
+ * @returns Promise with search count result
+ */
+ getSearchCount(
+ tableId: string,
+ queryRo: ISearchCountRo,
+ projection?: string[]
+ ): Promise<{ count: number }>;
+
+ /**
+ * Get record index by search order
+ * @param tableId - The table ID
+ * @param queryRo - Search index query parameters
+ * @param projection - Optional field projection
+ * @returns Promise with search index results
+ */
+ getRecordIndexBySearchOrder(
+ tableId: string,
+ queryRo: ISearchIndexByQueryRo,
+ projection?: string[]
+ ): Promise<
+ | {
+ index: number;
+ fieldId: string;
+ recordId: string;
+ }[]
+ | null
+ >;
+
+ /**
+ * Get the 0-based index of a specific record in the current query context
+ * @param tableId - The table ID
+ * @param queryRo - Query parameters including recordId and optional view/filter/sort
+ * @returns Promise - The record index or null if not found
+ */
+ getRecordIndex(tableId: string, queryRo: IRecordIndexRo): Promise;
+
+ /**
+ * Get calendar daily collection data
+ * @param tableId - The table ID
+ * @param query - Calendar collection query parameters
+ * @returns Promise - The calendar collection data
+ */
+ getCalendarDailyCollection(
+ tableId: string,
+ query: ICalendarDailyCollectionRo
+ ): Promise;
+}
+
+/**
+ * Interface for view-related parameters used in aggregation operations
+ */
+export interface IWithView {
+ viewId?: string;
+ groupBy?: IGroup;
+ customFilter?: IFilter;
+ customFieldStats?: ICustomFieldStats[];
+}
+
+/**
+ * Interface for custom field statistics configuration
+ */
+export interface ICustomFieldStats {
+ fieldId: string;
+ statisticFunc?: StatisticsFunc;
+}
diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.provider.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.provider.ts
new file mode 100644
index 0000000000..e5dcf83805
--- /dev/null
+++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.provider.ts
@@ -0,0 +1,16 @@
+import { Inject } from '@nestjs/common';
+import { AGGREGATION_SERVICE_SYMBOL } from './aggregation.service.symbol';
+
+/**
+ * Decorator for injecting the aggregation service
+ * Use this decorator instead of directly injecting the AggregationService class
+ *
+ * @example
+ * ```typescript
+ * constructor(
+ * @InjectAggregationService() private readonly aggregationService: IAggregationService
+ * ) {}
+ * ```
+ */
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const InjectAggregationService = () => Inject(AGGREGATION_SERVICE_SYMBOL);
diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.symbol.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.symbol.ts
new file mode 100644
index 0000000000..4abded98cf
--- /dev/null
+++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.symbol.ts
@@ -0,0 +1,6 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+/**
+ * Injection token for the aggregation service
+ * This symbol is used for dependency injection to avoid direct class references
+ */
+export const AGGREGATION_SERVICE_SYMBOL = Symbol('AGGREGATION_SERVICE');
diff --git a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts
index f0a00faf05..6d9893b5e4 100644
--- a/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts
+++ b/apps/nestjs-backend/src/features/aggregation/aggregation.service.ts
@@ -1,78 +1,96 @@
-import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
-import type {
- IAggregationField,
- IGridColumnMeta,
- IFilter,
- IGetRecordsRo,
- IQueryBaseRo,
- IRawAggregations,
- IRawAggregationValue,
- IRawRowCountValue,
- IGroupPoint,
- IGroupPointsRo,
-} from '@teable/core';
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import {
- DbFieldType,
- GroupPointType,
+ CellValueType,
+ HttpErrorCode,
+ extractFieldIdsFromFilter,
+ identify,
+ IdPrefix,
mergeWithDefaultFilter,
nullsToUndefined,
- parseGroup,
- StatisticsFunc,
ViewType,
} from '@teable/core';
+import type { IGridColumnMeta, IFilter, IGroup } from '@teable/core';
import type { Prisma } from '@teable/db-main-prisma';
import { PrismaService } from '@teable/db-main-prisma';
+import { StatisticsFunc } from '@teable/openapi';
+import type {
+ IAggregationField,
+ IQueryBaseRo,
+ IRawAggregationValue,
+ IRawAggregations,
+ IRawRowCountValue,
+ IGroupPointsRo,
+ IGroupPoint,
+ ICalendarDailyCollectionRo,
+ ICalendarDailyCollectionVo,
+ ISearchIndexByQueryRo,
+ ISearchCountRo,
+ IGetRecordsRo,
+ IRecordIndexRo,
+ IRecordIndexVo,
+} from '@teable/openapi';
import dayjs from 'dayjs';
import { Knex } from 'knex';
-import { groupBy, isDate, isEmpty, isObject } from 'lodash';
+import { groupBy, isDate, isEmpty, isString, keyBy } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { IThresholdConfig, ThresholdConfig } from '../../configs/threshold.config';
+import { CustomHttpException } from '../../custom.exception';
import { InjectDbProvider } from '../../db-provider/db.provider';
import { IDbProvider } from '../../db-provider/db.provider.interface';
import type { IClsStore } from '../../types/cls';
-import { string2Hash } from '../../utils';
-import { Timing } from '../../utils/timing';
-import type { IFieldInstance } from '../field/model/factory';
-import { createFieldInstanceByRaw } from '../field/model/factory';
+import { convertValueToStringify, string2Hash } from '../../utils';
+import { createFieldInstanceByRaw, type IFieldInstance } from '../field/model/factory';
+import type { DateFieldDto } from '../field/model/field-dto/date-field.dto';
+import { InjectRecordQueryBuilder, IRecordQueryBuilder } from '../record/query-builder';
+import { RecordPermissionService } from '../record/record-permission.service';
import { RecordService } from '../record/record.service';
-
-export type IWithView = {
- viewId?: string;
- customFilter?: IFilter;
- customFieldStats?: ICustomFieldStats[];
-};
-
-type ICustomFieldStats = {
- fieldId: string;
- statisticFunc?: StatisticsFunc;
-};
+import { TableIndexService } from '../table/table-index.service';
+import type {
+ IAggregationService,
+ ICustomFieldStats,
+ IWithView,
+} from './aggregation.service.interface';
type IStatisticsData = {
viewId?: string;
filter?: IFilter;
statisticFields?: IAggregationField[];
};
-
+/**
+ * Version 2 implementation of the aggregation service
+ * This is a placeholder implementation that will be developed in the future
+ * All methods currently throw NotImplementedException
+ */
@Injectable()
-export class AggregationService {
+export class AggregationService implements IAggregationService {
private logger = new Logger(AggregationService.name);
-
constructor(
private readonly recordService: RecordService,
+ private readonly tableIndexService: TableIndexService,
private readonly prisma: PrismaService,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
@InjectDbProvider() private readonly dbProvider: IDbProvider,
+ @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
private readonly cls: ClsService,
- @ThresholdConfig() private readonly thresholdConfig: IThresholdConfig
+ private readonly recordPermissionService: RecordPermissionService,
+ @InjectRecordQueryBuilder() private readonly recordQueryBuilder: IRecordQueryBuilder
) {}
-
+ /**
+ * Perform aggregation operations on table data
+ * @param params - Parameters for aggregation including tableId, field IDs, view settings, and search
+ * @returns Promise - The aggregation results
+ * @throws NotImplementedException - This method is not yet implemented
+ */
async performAggregation(params: {
tableId: string;
withFieldIds?: string[];
withView?: IWithView;
+ search?: [string, string?, boolean?];
+ useQueryModel?: boolean;
}): Promise {
- const { tableId, withFieldIds, withView } = params;
+ const { tableId, withFieldIds, withView, search, useQueryModel } = params;
// Retrieve the current user's ID to build user-related query conditions
const currentUserId = this.cls.get('user.id');
@@ -85,13 +103,17 @@ export class AggregationService {
const dbTableName = await this.getDbTableName(this.prisma, tableId);
const { filter, statisticFields } = statisticsData;
-
+ const groupBy = withView?.groupBy;
const rawAggregationData = await this.handleAggregation({
dbTableName,
fieldInstanceMap,
+ tableId,
filter,
+ search,
statisticFields,
withUserId: currentUserId,
+ withView,
+ useQueryModel,
});
const aggregationResult = rawAggregationData && rawAggregationData[0];
@@ -99,7 +121,14 @@ export class AggregationService {
const aggregations: IRawAggregations = [];
if (aggregationResult) {
for (const [key, value] of Object.entries(aggregationResult)) {
- const [fieldId, aggFunc] = key.split('_') as [string, StatisticsFunc | undefined];
+ // Match by alias to ensure uniqueness across different functions of the same field
+ const statisticField = statisticFields?.find(
+ (item) => item.alias === key || item.fieldId === key
+ );
+ if (!statisticField) {
+ continue;
+ }
+ const { fieldId, statisticFunc: aggFunc } = statisticField;
const convertValue = this.formatConvertValue(value, aggFunc);
@@ -111,18 +140,320 @@ export class AggregationService {
}
}
}
- return { aggregations };
+
+ const aggregationsWithGroup = await this.performGroupedAggregation({
+ aggregations,
+ statisticFields,
+ tableId,
+ filter,
+ search,
+ groupBy,
+ dbTableName,
+ fieldInstanceMap,
+ withView,
+ useQueryModel,
+ });
+
+ return { aggregations: aggregationsWithGroup };
+ }
+
+ private formatConvertValue = (currentValue: unknown, aggFunc?: StatisticsFunc) => {
+ let convertValue = this.convertValueToNumberOrString(currentValue);
+
+ if (!aggFunc) {
+ return convertValue;
+ }
+
+ if (aggFunc === StatisticsFunc.DateRangeOfMonths && typeof currentValue === 'string') {
+ convertValue = this.calculateDateRangeOfMonths(currentValue);
+ }
+
+ const defaultToZero = [
+ StatisticsFunc.PercentEmpty,
+ StatisticsFunc.PercentFilled,
+ StatisticsFunc.PercentUnique,
+ StatisticsFunc.PercentChecked,
+ StatisticsFunc.PercentUnChecked,
+ ];
+
+ if (defaultToZero.includes(aggFunc)) {
+ convertValue = convertValue ?? 0;
+ }
+ return convertValue;
+ };
+
+ private convertValueToNumberOrString(currentValue: unknown): number | string | null {
+ if (typeof currentValue === 'bigint' || typeof currentValue === 'number') {
+ return Number(currentValue);
+ }
+ if (isDate(currentValue)) {
+ return currentValue.toISOString();
+ }
+ return currentValue?.toString() ?? null;
}
+ private calculateDateRangeOfMonths(currentValue: string): number {
+ const [maxTime, minTime] = currentValue.split(',');
+ return maxTime && minTime ? dayjs(maxTime).diff(minTime, 'month') : 0;
+ }
+ private async handleAggregation(params: {
+ dbTableName: string;
+ fieldInstanceMap: Record;
+ tableId: string;
+ filter?: IFilter;
+ groupBy?: IGroup;
+ search?: [string, string?, boolean?];
+ statisticFields?: IAggregationField[];
+ withUserId?: string;
+ withView?: IWithView;
+ useQueryModel?: boolean;
+ }) {
+ const {
+ dbTableName,
+ fieldInstanceMap,
+ filter,
+ search,
+ statisticFields,
+ withUserId,
+ groupBy,
+ withView,
+ tableId,
+ useQueryModel,
+ } = params;
+
+ if (!statisticFields?.length) {
+ return;
+ }
+
+ const { viewId } = withView || {};
+
+ // Probe permission to get enabled field IDs for CTE projection
+ const permissionProbe = await this.recordPermissionService.wrapView(
+ tableId,
+ this.knex.queryBuilder(),
+ { viewId }
+ );
+ const allowedFieldIds = permissionProbe.enabledFieldIds;
+
+ const searchFields = await this.recordService.getSearchFields(
+ fieldInstanceMap,
+ search,
+ viewId,
+ allowedFieldIds
+ );
+
+ const projection = this.resolveAggregationProjection({
+ statisticFields,
+ groupBy,
+ filter,
+ searchFields,
+ allowedFieldIds,
+ });
+
+ // Build aggregate query using the permission-aware builder so the CTE is preserved
+ const { qb, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(
+ permissionProbe.viewCte ?? dbTableName,
+ {
+ tableId,
+ viewId,
+ filter,
+ aggregationFields: statisticFields,
+ groupBy,
+ currentUserId: withUserId,
+ // Limit link/lookup CTEs to enabled fields so denied fields resolve to NULL
+ projection,
+ useQueryModel,
+ builder: permissionProbe.builder,
+ }
+ );
+
+ if (search && search[2] && searchFields?.length) {
+ const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
+ qb.where((builder) => {
+ this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
+ });
+ }
+
+ if (groupBy?.length) {
+ qb.limit(this.thresholdConfig.maxGroupPoints);
+ }
+
+ const aggSql = qb.toQuery();
+ this.logger.debug('handleAggregation aggSql: %s', aggSql);
+ return this.prisma.$queryRawUnsafe<{ [field: string]: unknown }[]>(aggSql);
+ }
+ /**
+ * Perform grouped aggregation operations
+ * @param params - Parameters for grouped aggregation
+ * @returns Promise - The grouped aggregation results
+ * @throws NotImplementedException - This method is not yet implemented
+ */
+
+ async performGroupedAggregation(params: {
+ aggregations: IRawAggregations;
+ statisticFields: IAggregationField[] | undefined;
+ tableId: string;
+ filter?: IFilter;
+ search?: [string, string?, boolean?];
+ groupBy?: IGroup;
+ dbTableName: string;
+ fieldInstanceMap: Record;
+ withView?: IWithView;
+ useQueryModel?: boolean;
+ }) {
+ const {
+ dbTableName,
+ aggregations,
+ statisticFields,
+ filter,
+ groupBy,
+ search,
+ fieldInstanceMap,
+ withView,
+ tableId,
+ useQueryModel,
+ } = params;
+
+ if (!groupBy || !statisticFields) return aggregations;
+
+ const currentUserId = this.cls.get('user.id');
+ const aggregationByFieldId = keyBy(aggregations, 'fieldId');
+
+ const groupByFields = groupBy.map(({ fieldId }) => {
+ return {
+ fieldId,
+ dbFieldName: fieldInstanceMap[fieldId].dbFieldName,
+ };
+ });
+
+ for (let i = 0; i < groupBy.length; i++) {
+ const rawGroupedAggregationData = (await this.handleAggregation({
+ dbTableName,
+ fieldInstanceMap,
+ tableId,
+ filter,
+ groupBy: groupBy.slice(0, i + 1),
+ search,
+ statisticFields,
+ withUserId: currentUserId,
+ withView,
+ useQueryModel,
+ }))!;
+
+ const currentGroupFieldId = groupByFields[i].fieldId;
+
+ for (const groupedAggregation of rawGroupedAggregationData) {
+ const groupByValueString = groupByFields
+ .slice(0, i + 1)
+ .map(({ dbFieldName }) => {
+ const groupByValue = groupedAggregation[dbFieldName];
+ return convertValueToStringify(groupByValue);
+ })
+ .join('_');
+ const flagString = `${currentGroupFieldId}_${groupByValueString}`;
+ const groupId = String(string2Hash(flagString));
+
+ for (const statisticField of statisticFields) {
+ const { fieldId, statisticFunc, alias } = statisticField;
+ // Use unique alias to read the correct aggregated column
+ const aggKey = alias ?? `${fieldId}_${statisticFunc}`;
+ const curFieldAggregation = aggregationByFieldId[fieldId]!;
+ const convertValue = this.formatConvertValue(groupedAggregation[aggKey], statisticFunc);
+
+ if (!curFieldAggregation.group) {
+ aggregationByFieldId[fieldId].group = {
+ [groupId]: { value: convertValue, aggFunc: statisticFunc },
+ };
+ } else {
+ aggregationByFieldId[fieldId]!.group![groupId] = {
+ value: convertValue,
+ aggFunc: statisticFunc,
+ };
+ }
+ }
+ }
+ }
+
+ return Object.values(aggregationByFieldId);
+ }
+
+ /**
+ * Determine required projection for aggregation query.
+ */
+ private resolveAggregationProjection(params: {
+ statisticFields?: IAggregationField[];
+ groupBy?: IGroup;
+ filter?: IFilter;
+ searchFields?: IFieldInstance[];
+ allowedFieldIds?: string[];
+ }): string[] | undefined {
+ const { statisticFields, groupBy, filter, searchFields, allowedFieldIds } = params;
+
+ const projectionSet = new Set();
+
+ statisticFields?.forEach(({ fieldId }) => {
+ if (fieldId && fieldId !== '*') {
+ projectionSet.add(fieldId);
+ }
+ });
+
+ groupBy?.forEach(({ fieldId }) => {
+ if (fieldId) {
+ projectionSet.add(fieldId);
+ }
+ });
+
+ if (filter) {
+ for (const fieldId of extractFieldIdsFromFilter(filter)) {
+ projectionSet.add(fieldId);
+ }
+ }
+
+ searchFields?.forEach((fieldInstance) => {
+ projectionSet.add(fieldInstance.id);
+ });
+
+ if (projectionSet.size === 0) {
+ return allowedFieldIds && allowedFieldIds.length
+ ? Array.from(new Set(allowedFieldIds))
+ : undefined;
+ }
+
+ const projectionArray = Array.from(projectionSet);
+
+ if (!allowedFieldIds || allowedFieldIds.length === 0) {
+ return projectionArray;
+ }
+
+ const allowedSet = new Set(allowedFieldIds);
+ const filtered = projectionArray.filter((fieldId) => allowedSet.has(fieldId));
+
+ return filtered.length > 0 ? filtered : Array.from(allowedSet);
+ }
+
+ /**
+ * Get row count for a table with optional filtering
+ * @param tableId - The table ID
+ * @param queryRo - Query parameters for filtering
+ * @returns Promise - The row count result
+ * @throws NotImplementedException - This method is not yet implemented
+ */
async performRowCount(tableId: string, queryRo: IQueryBaseRo): Promise {
- const { filterLinkCellCandidate, filterLinkCellSelected } = queryRo;
+ const {
+ viewId,
+ ignoreViewQuery,
+ filterLinkCellCandidate,
+ filterLinkCellSelected,
+ selectedRecordIds,
+ search,
+ } = queryRo;
// Retrieve the current user's ID to build user-related query conditions
const currentUserId = this.cls.get('user.id');
const { statisticsData, fieldInstanceMap } = await this.fetchStatisticsParams({
tableId,
withView: {
- viewId: queryRo.viewId,
+ viewId: ignoreViewQuery ? undefined : viewId,
customFilter: queryRo.filter,
},
});
@@ -131,25 +462,122 @@ export class AggregationService {
const { filter } = statisticsData;
- if (filterLinkCellSelected) {
- // TODO: use a new method to retrieve only count
- const { ids } = await this.recordService.getLinkSelectedRecordIds(filterLinkCellSelected);
- return { rowCount: ids.length };
- }
-
const rawRowCountData = await this.handleRowCount({
tableId,
dbTableName,
fieldInstanceMap,
filter,
filterLinkCellCandidate,
+ filterLinkCellSelected,
+ selectedRecordIds,
+ search,
withUserId: currentUserId,
+ viewId: queryRo?.viewId,
});
+
return {
- rowCount: Number(rawRowCountData[0]?.count ?? 0),
+ rowCount: Number(rawRowCountData?.[0]?.count ?? 0),
};
}
+ private async getDbTableName(prisma: Prisma.TransactionClient, tableId: string) {
+ const tableMeta = await prisma.tableMeta.findUniqueOrThrow({
+ where: { id: tableId },
+ select: { dbTableName: true },
+ });
+ return tableMeta.dbTableName;
+ }
+ private async handleRowCount(params: {
+ tableId: string;
+ dbTableName: string;
+ fieldInstanceMap: Record;
+ filter?: IFilter;
+ filterLinkCellCandidate?: IGetRecordsRo['filterLinkCellCandidate'];
+ filterLinkCellSelected?: IGetRecordsRo['filterLinkCellSelected'];
+ selectedRecordIds?: IGetRecordsRo['selectedRecordIds'];
+ search?: [string, string?, boolean?];
+ withUserId?: string;
+ viewId?: string;
+ }) {
+ const {
+ tableId,
+ dbTableName,
+ fieldInstanceMap,
+ filter,
+ filterLinkCellCandidate,
+ filterLinkCellSelected,
+ selectedRecordIds,
+ search,
+ withUserId,
+ viewId,
+ } = params;
+
+ const restrictRecordIds =
+ selectedRecordIds && !filterLinkCellCandidate ? selectedRecordIds : undefined;
+
+ const wrap = await this.recordPermissionService.wrapView(tableId, this.knex.queryBuilder(), {
+ viewId,
+ keepPrimaryKey: Boolean(filterLinkCellSelected),
+ });
+
+ const { qb, alias, selectionMap } = await this.recordQueryBuilder.createRecordAggregateBuilder(
+ wrap.viewCte ?? dbTableName,
+ {
+ tableId,
+ viewId,
+ currentUserId: withUserId,
+ filter,
+ aggregationFields: [
+ {
+ fieldId: '*',
+ statisticFunc: StatisticsFunc.Count,
+ alias: 'count',
+ },
+ ],
+ restrictRecordIds,
+ useQueryModel: true,
+ builder: wrap.builder,
+ }
+ );
+
+ if (search && search[2]) {
+ const searchFields = await this.recordService.getSearchFields(
+ fieldInstanceMap,
+ search,
+ viewId
+ );
+ const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
+ qb.where((builder) => {
+ this.dbProvider.searchQuery(builder, searchFields, tableIndex, search, { selectionMap });
+ });
+ }
+
+ if (selectedRecordIds) {
+ filterLinkCellCandidate
+ ? qb.whereNotIn(`${alias}.__id`, selectedRecordIds)
+ : qb.whereIn(`${alias}.__id`, selectedRecordIds);
+ }
+
+ if (filterLinkCellCandidate) {
+ await this.recordService.buildLinkCandidateQuery(qb, tableId, filterLinkCellCandidate);
+ }
+
+ if (filterLinkCellSelected) {
+ await this.recordService.buildLinkSelectedQuery(
+ qb,
+ tableId,
+ dbTableName,
+ alias,
+ filterLinkCellSelected
+ );
+ }
+
+ const rawQuery = qb.toQuery();
+
+ this.logger.debug('handleRowCount raw query: %s', rawQuery);
+ return await this.prisma.$queryRawUnsafe<{ count: number }[]>(rawQuery);
+ }
+
private async fetchStatisticsParams(params: {
tableId: string;
withView?: IWithView;
@@ -170,6 +598,7 @@ export class AggregationService {
);
const statisticsData = this.buildStatisticsData(filteredFieldInstances, viewRaw, withView);
+
return { statisticsData, fieldInstanceMap };
}
@@ -180,11 +609,20 @@ export class AggregationService {
return nullsToUndefined(
await this.prisma.view.findFirst({
- select: { id: true, columnMeta: true, filter: true, group: true },
+ select: {
+ id: true,
+ type: true,
+ filter: true,
+ group: true,
+ options: true,
+ columnMeta: true,
+ },
where: {
tableId,
...(withView?.viewId ? { id: withView.viewId } : {}),
- type: { in: [ViewType.Grid, ViewType.Gantt] },
+ type: {
+ in: [ViewType.Grid, ViewType.Kanban, ViewType.Gallery, ViewType.Calendar],
+ },
deletedTime: null,
},
})
@@ -236,23 +674,6 @@ export class AggregationService {
return statisticsData;
}
- async getFieldsData(tableId: string, fieldIds?: string[]) {
- const fieldsRaw = await this.prisma.field.findMany({
- where: { tableId, ...(fieldIds ? { id: { in: fieldIds } } : {}), deletedTime: null },
- });
-
- const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));
- const fieldInstanceMap = fieldInstances.reduce(
- (map, field) => {
- map[field.id] = field;
- map[field.name] = field;
- return map;
- },
- {} as Record
- );
- return { fieldInstances, fieldInstanceMap };
- }
-
private getStatisticFields(
fieldInstances: IFieldInstance[],
columnMeta?: IGridColumnMeta,
@@ -281,6 +702,8 @@ export class AggregationService {
return {
fieldId,
statisticFunc: item,
+ // Ensure unique alias per function to avoid collisions in result set
+ alias: `${fieldId}_${item}`,
};
});
(calculatedStatisticFields = calculatedStatisticFields ?? []).push(...statisticFieldList);
@@ -289,245 +712,489 @@ export class AggregationService {
});
return calculatedStatisticFields;
}
+ /**
+ * Get field data for a table
+ * @param tableId - The table ID
+ * @param fieldIds - Optional array of field IDs to filter
+ * @param withName - Whether to include field names in the mapping
+ * @returns Promise with field instances and field instance map
+ * @throws NotImplementedException - This method is not yet implemented
+ */
+
+ async getFieldsData(tableId: string, fieldIds?: string[], withName?: boolean) {
+ const fieldsRaw = await this.prisma.field.findMany({
+ where: { tableId, ...(fieldIds ? { id: { in: fieldIds } } : {}), deletedTime: null },
+ });
- private handleAggregation(params: {
- dbTableName: string;
- fieldInstanceMap: Record;
- filter?: IFilter;
- statisticFields?: IAggregationField[];
- withUserId?: string;
- }) {
- const { dbTableName, fieldInstanceMap, filter, statisticFields, withUserId } = params;
- if (!statisticFields?.length) {
- return;
- }
-
- const tableAlias = 'main_table';
- const queryBuilder = this.knex
- .with(tableAlias, (qb) => {
- qb.select('*').from(dbTableName);
- if (filter) {
- this.dbProvider
- .filterQuery(qb, fieldInstanceMap, filter, { withUserId })
- .appendQueryBuilder();
+ const fieldInstances = fieldsRaw.map((field) => createFieldInstanceByRaw(field));
+ const fieldInstanceMap = fieldInstances.reduce(
+ (map, field) => {
+ map[field.id] = field;
+ if (withName || withName === undefined) {
+ map[field.name] = field;
}
- })
- .from(tableAlias);
-
- const aggSql = this.dbProvider
- .aggregationQuery(queryBuilder, tableAlias, fieldInstanceMap, statisticFields)
- .toQuerySql();
- return this.prisma.$queryRawUnsafe<{ [field: string]: unknown }[]>(aggSql);
+ return map;
+ },
+ {} as Record
+ );
+ return { fieldInstances, fieldInstanceMap };
+ } /**
+ * Get group points for a table
+ * @param tableId - The table ID
+ * @param query - Optional query parameters
+ * @returns Promise with group points data
+ * @throws NotImplementedException - This method is not yet implemented
+ */
+ async getGroupPoints(
+ tableId: string,
+ query?: IGroupPointsRo,
+ useQueryModel = false
+ ): Promise {
+ const { groupPoints } = await this.recordService.getGroupRelatedData(
+ tableId,
+ query,
+ useQueryModel
+ );
+ return groupPoints;
}
- private async handleRowCount(params: {
- tableId: string;
- dbTableName: string;
- fieldInstanceMap: Record;
- filter?: IFilter;
- filterLinkCellCandidate?: IGetRecordsRo['filterLinkCellCandidate'];
- withUserId?: string;
- }) {
- const { tableId, dbTableName, fieldInstanceMap, filter, filterLinkCellCandidate, withUserId } =
- params;
+ /**
+ * Get search count for a table
+ * @param tableId - The table ID
+ * @param queryRo - Search query parameters
+ * @param projection - Optional field projection
+ * @returns Promise with search count result
+ * @throws NotImplementedException - This method is not yet implemented
+ */
+
+ public async getSearchCount(tableId: string, queryRo: ISearchCountRo, projection?: string[]) {
+ const { search, viewId, ignoreViewQuery } = queryRo;
+ const dbFieldName = await this.getDbTableName(this.prisma, tableId);
+ const { fieldInstanceMap } = await this.getFieldsData(tableId, undefined, false);
+
+ if (!search) {
+ throw new CustomHttpException('Search query is required', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.searchQueryRequired',
+ },
+ });
+ }
- const queryBuilder = this.knex(dbTableName);
+ const searchFields = await this.recordService.getSearchFields(
+ fieldInstanceMap,
+ search,
+ ignoreViewQuery ? undefined : viewId,
+ projection
+ );
- if (filter) {
- this.dbProvider
- .filterQuery(queryBuilder, fieldInstanceMap, filter, { withUserId })
- .appendQueryBuilder();
+ if (searchFields?.length === 0) {
+ return { count: 0 };
}
+ const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
+ const queryBuilder = this.knex(dbFieldName);
- if (filterLinkCellCandidate) {
- await this.recordService.buildLinkCandidateQuery(
+ const selectionMap = new Map(
+ Object.values(fieldInstanceMap).map((f) => [f.id, `"${f.dbFieldName}"`])
+ );
+ this.dbProvider.searchCountQuery(queryBuilder, searchFields, search, tableIndex, {
+ selectionMap,
+ });
+ this.dbProvider
+ .filterQuery(
queryBuilder,
- tableId,
- filterLinkCellCandidate
- );
- }
+ fieldInstanceMap,
+ queryRo?.filter,
+ {
+ withUserId: this.cls.get('user.id'),
+ },
+ { selectionMap }
+ )
+ .appendQueryBuilder();
+
+ const sql = queryBuilder.toQuery();
+
+ const result = await this.prisma.$queryRawUnsafe<{ count: number }[] | null>(sql);
- return this.getRowCount(this.prisma, queryBuilder);
+ return {
+ count: result ? Number(result[0]?.count) : 0,
+ };
}
- private convertValueToNumberOrString(currentValue: unknown): number | string | null {
- if (typeof currentValue === 'bigint' || typeof currentValue === 'number') {
- return Number(currentValue);
+ public async getRecordIndexBySearchOrder(
+ tableId: string,
+ queryRo: ISearchIndexByQueryRo,
+ projection?: string[]
+ ) {
+ const {
+ search,
+ take,
+ skip,
+ orderBy,
+ filter,
+ groupBy,
+ viewId,
+ ignoreViewQuery,
+ projection: queryProjection,
+ } = queryRo;
+ const dbTableName = await this.getDbTableName(this.prisma, tableId);
+ const { fieldInstanceMap } = await this.getFieldsData(tableId, undefined, false);
+
+ if (take > 1000) {
+ throw new CustomHttpException(
+ 'The maximum search index result is 1000',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.maxSearchIndexResult',
+ },
+ }
+ );
}
- if (isDate(currentValue)) {
- return currentValue.toISOString();
+
+ if (!search) {
+ throw new CustomHttpException('Search query is required', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.searchQueryRequired',
+ },
+ });
}
- return currentValue?.toString() ?? null;
- }
- private calculateDateRangeOfMonths(currentValue: string): number {
- const [maxTime, minTime] = currentValue.split(',');
- return maxTime && minTime ? dayjs(maxTime).diff(minTime, 'month') : 0;
- }
+ const finalProjection = queryProjection
+ ? projection
+ ? projection.filter((fieldId) => queryProjection.includes(fieldId))
+ : queryProjection
+ : projection;
- private formatConvertValue = (currentValue: unknown, aggFunc?: StatisticsFunc) => {
- let convertValue = this.convertValueToNumberOrString(currentValue);
+ const searchFields = await this.recordService.getSearchFields(
+ fieldInstanceMap,
+ search,
+ ignoreViewQuery ? undefined : viewId,
+ finalProjection
+ );
- if (!aggFunc) {
- return convertValue;
+ if (searchFields.length === 0) {
+ return null;
}
- if (aggFunc === StatisticsFunc.DateRangeOfMonths && typeof currentValue === 'string') {
- convertValue = this.calculateDateRangeOfMonths(currentValue);
- }
+ const selectionMap = new Map(
+ Object.values(fieldInstanceMap).map((f) => [f.id, `"${f.dbFieldName}"`])
+ );
- const defaultToZero = [
- StatisticsFunc.PercentEmpty,
- StatisticsFunc.PercentFilled,
- StatisticsFunc.PercentUnique,
- StatisticsFunc.PercentChecked,
- StatisticsFunc.PercentUnChecked,
- ];
+ const basicSortIndex = await this.recordService.getBasicOrderIndexField(dbTableName, viewId);
- if (defaultToZero.includes(aggFunc)) {
- convertValue = convertValue ?? 0;
- }
- return convertValue;
- };
+ const filterQuery = (qb: Knex.QueryBuilder) => {
+ this.dbProvider
+ .filterQuery(
+ qb,
+ fieldInstanceMap,
+ filter,
+ {
+ withUserId: this.cls.get('user.id'),
+ },
+ { selectionMap }
+ )
+ .appendQueryBuilder();
+ };
- private async getDbTableName(prisma: Prisma.TransactionClient, tableId: string) {
- const tableMeta = await prisma.tableMeta.findUniqueOrThrow({
- where: { id: tableId },
- select: { dbTableName: true },
- });
- return tableMeta.dbTableName;
- }
+ const sortQuery = (qb: Knex.QueryBuilder) => {
+ this.dbProvider
+ .sortQuery(qb, fieldInstanceMap, [...(groupBy ?? []), ...(orderBy ?? [])], undefined, {
+ selectionMap,
+ })
+ .appendSortBuilder();
+ };
- private async getRowCount(prisma: Prisma.TransactionClient, queryBuilder: Knex.QueryBuilder) {
- queryBuilder
- .clearSelect()
- .clearCounters()
- .clearGroup()
- .clearHaving()
- .clearOrder()
- .clear('limit')
- .clear('offset');
- const rowCountSql = queryBuilder.count({ count: '*' });
-
- return prisma.$queryRawUnsafe<{ count?: number }[]>(rowCountSql.toQuery());
- }
+ const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
- @Timing()
- private groupDbCollection2GroupPoints(
- groupResult: { [key: string]: unknown; __c: number }[],
- groupFields: IFieldInstance[]
- ) {
- const groupPoints: IGroupPoint[] = [];
- let fieldValues: unknown[] = [Symbol(), Symbol(), Symbol()];
+ const { viewCte, builder } = await this.recordPermissionService.wrapView(
+ tableId,
+ this.knex.queryBuilder(),
+ {
+ viewId,
+ keepPrimaryKey: Boolean(queryRo.filterLinkCellSelected),
+ }
+ );
- groupResult.forEach((item) => {
- const { __c: count } = item;
+ const queryBuilder = this.dbProvider.searchIndexQuery(
+ builder,
+ viewCte || dbTableName,
+ searchFields,
+ queryRo,
+ tableIndex,
+ { selectionMap },
+ basicSortIndex,
+ filterQuery,
+ sortQuery
+ );
+
+ const sql = queryBuilder.toQuery();
+
+ this.logger.debug('getRecordIndexBySearchOrder sql: %s', sql);
- groupFields.forEach((field, index) => {
- const { id, dbFieldName } = field;
- const fieldValue = isObject(item[dbFieldName])
- ? String(item[dbFieldName])
- : item[dbFieldName];
+ try {
+ return await this.prisma.$tx(async (prisma) => {
+ const result = await prisma.$queryRawUnsafe<{ __id: string; fieldId: string }[]>(sql);
- if (fieldValues[index] === fieldValue) return;
+ // no result found
+ if (result?.length === 0) {
+ return null;
+ }
+
+ const recordIds = result;
- fieldValues[index] = fieldValue;
- fieldValues = fieldValues.map((value, idx) => (idx > index ? Symbol() : value));
+ if (search[2]) {
+ const baseSkip = skip ?? 0;
+ const accRecord: string[] = [];
+ return recordIds.map((rec) => {
+ if (!accRecord?.includes(rec.__id)) {
+ accRecord.push(rec.__id);
+ }
+ return {
+ index: baseSkip + accRecord?.length,
+ fieldId: rec.fieldId,
+ recordId: rec.__id,
+ };
+ });
+ }
- const flagString = `${id}_${fieldValues.slice(0, index + 1).join('_')}`;
+ const { queryBuilder: viewRecordsQB, alias } =
+ await this.recordService.buildFilterSortQuery(tableId, queryRo, true);
+ // step 2. find the index in current view
+ const indexQueryBuilder = this.knex
+ .with('t', viewRecordsQB.from({ [alias]: viewCte || dbTableName }))
+ .with('t1', (db) => {
+ db.select('__id').select(this.knex.raw('ROW_NUMBER() OVER () as row_num')).from('t');
+ })
+ .select('t1.row_num')
+ .select('t1.__id')
+ .from('t1')
+ .whereIn('t1.__id', [...new Set(recordIds.map((record) => record.__id))]);
+
+ const indexSql = indexQueryBuilder.toQuery();
+ this.logger.debug('getRecordIndexBySearchOrder indexSql: %s', indexSql);
+ const indexResult =
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ await this.prisma.$queryRawUnsafe<{ row_num: number; __id: string }[]>(indexSql);
+
+ if (indexResult?.length === 0) {
+ return null;
+ }
- groupPoints.push({
- id: String(string2Hash(flagString)),
- type: GroupPointType.Header,
- depth: index,
- value: field.convertDBValue2CellValue(fieldValue),
+ const indexResultMap = keyBy(indexResult, '__id');
+
+ return result.map((item) => {
+ const index = Number(indexResultMap[item.__id]?.row_num);
+ if (isNaN(index)) {
+ throw new CustomHttpException('Index not found', HttpErrorCode.NOT_FOUND, {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.indexNotFound',
+ },
+ });
+ }
+ return {
+ index,
+ fieldId: item.fieldId,
+ recordId: item.__id,
+ };
});
});
-
- groupPoints.push({ type: GroupPointType.Row, count: Number(count) });
- });
- return groupPoints;
+ } catch (error) {
+ if (error instanceof PrismaClientKnownRequestError && error.code === 'P2028') {
+ throw new CustomHttpException(`${error.message}`, HttpErrorCode.REQUEST_TIMEOUT, {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.searchTimeOut',
+ },
+ });
+ }
+ throw error;
+ }
}
+ async getRecordIndex(tableId: string, queryRo: IRecordIndexRo): Promise {
+ const { recordId } = queryRo;
- private async checkGroupingOverLimit(dbFieldNames: string[], queryBuilder: Knex.QueryBuilder) {
- queryBuilder.countDistinct(dbFieldNames);
+ const { queryBuilder: viewRecordsQB, alias } = await this.recordService.buildFilterSortQuery(
+ tableId,
+ { ...queryRo, skip: undefined, take: undefined },
+ true
+ );
- const distinctResult = await this.prisma.$queryRawUnsafe<{ count: number }[]>(
- queryBuilder.toQuery()
+ const dbTableName = await this.getDbTableName(this.prisma, tableId);
+
+ const { viewCte } = await this.recordPermissionService.wrapView(
+ tableId,
+ this.knex.queryBuilder(),
+ { viewId: queryRo.viewId }
);
- const distinctCount = Number(distinctResult[0].count);
- return distinctCount > this.thresholdConfig.maxGroupPoints;
+ const indexQueryBuilder = this.knex
+ .with('t', viewRecordsQB.from({ [alias]: viewCte || dbTableName }))
+ .with('t1', (db) => {
+ db.select('__id').select(this.knex.raw('ROW_NUMBER() OVER () as row_num')).from('t');
+ })
+ .select('t1.row_num')
+ .from('t1')
+ .where('t1.__id', recordId);
+
+ const sql = indexQueryBuilder.toQuery();
+ this.logger.debug('getRecordIndex sql: %s', sql);
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const result = await this.prisma.$queryRawUnsafe<{ row_num: number }[]>(sql);
+
+ if (!result?.length) {
+ return null;
+ }
+
+ return { index: Number(result[0].row_num) - 1 };
}
- public async getGroupPoints(tableId: string, query?: IGroupPointsRo) {
- const { viewId, groupBy: extraGroupBy, filter } = query || {};
+ /**
+ * Get calendar daily collection data
+ * @param tableId - The table ID
+ * @param query - Calendar collection query parameters
+ * @returns Promise - The calendar collection data
+ * @throws NotImplementedException - This method is not yet implemented
+ */
+
+ public async getCalendarDailyCollection(
+ tableId: string,
+ query: ICalendarDailyCollectionRo
+ ): Promise {
+ const {
+ startDate,
+ endDate,
+ startDateFieldId,
+ endDateFieldId,
+ filter,
+ search,
+ ignoreViewQuery,
+ } = query;
+
+ if (identify(tableId) !== IdPrefix.Table) {
+ throw new CustomHttpException(
+ 'query collection must be table id',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.queryCollectionMustBeTableId',
+ },
+ }
+ );
+ }
- if (!viewId) return null;
+ const fields = await this.recordService.getFieldsByProjection(tableId);
+ const fieldMap = fields.reduce(
+ (map, field) => {
+ map[field.id] = field;
+ return map;
+ },
+ {} as Record
+ );
- const groupBy = parseGroup(extraGroupBy);
+ const startField = fieldMap[startDateFieldId];
+ if (
+ !startField ||
+ startField.cellValueType !== CellValueType.DateTime ||
+ startField.isMultipleCellValue
+ ) {
+ throw new CustomHttpException('Invalid start date field id', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.invalidStartDateFieldId',
+ },
+ });
+ }
- if (!groupBy?.length) return null;
+ const endField = endDateFieldId ? fieldMap[endDateFieldId] : startField;
- const viewRaw = await this.findView(tableId, { viewId });
- const { fieldInstanceMap } = await this.getFieldsData(tableId);
- const dbTableName = await this.getDbTableName(this.prisma, tableId);
+ if (
+ !endField ||
+ endField.cellValueType !== CellValueType.DateTime ||
+ endField.isMultipleCellValue
+ ) {
+ throw new CustomHttpException('Invalid end date field id', HttpErrorCode.VALIDATION_ERROR, {
+ localization: {
+ i18nKey: 'httpErrors.aggregation.invalidEndDateFieldId',
+ },
+ });
+ }
+ const viewId = ignoreViewQuery ? undefined : query.viewId;
+ const dbTableName = await this.getDbTableName(this.prisma, tableId);
+ const { viewCte, builder: queryBuilder } = await this.recordPermissionService.wrapView(
+ tableId,
+ this.knex.queryBuilder(),
+ {
+ viewId,
+ }
+ );
+ queryBuilder.from(viewCte || dbTableName);
+ const viewRaw = await this.findView(tableId, { viewId });
const filterStr = viewRaw?.filter;
const mergedFilter = mergeWithDefaultFilter(filterStr, filter);
- const groupFieldIds = groupBy.map((item) => item.fieldId);
-
- const queryBuilder = this.knex(dbTableName);
- const distinctQueryBuilder = this.knex(dbTableName);
+ const currentUserId = this.cls.get('user.id');
+ const selectionMap = new Map(Object.values(fieldMap).map((f) => [f.id, `"${f.dbFieldName}"`]));
if (mergedFilter) {
- const withUserId = this.cls.get('user.id');
this.dbProvider
- .filterQuery(queryBuilder, fieldInstanceMap, mergedFilter, { withUserId })
- .appendQueryBuilder();
- this.dbProvider
- .filterQuery(distinctQueryBuilder, fieldInstanceMap, mergedFilter, { withUserId })
+ .filterQuery(
+ queryBuilder,
+ fieldMap,
+ mergedFilter,
+ { withUserId: currentUserId },
+ { selectionMap }
+ )
.appendQueryBuilder();
}
- const dbFieldNames = groupFieldIds.map((fieldId) => fieldInstanceMap[fieldId].dbFieldName);
-
- const isGroupingOverLimit = await this.checkGroupingOverLimit(
- dbFieldNames,
- distinctQueryBuilder
- );
- if (isGroupingOverLimit) {
- throw new HttpException(
- 'Grouping results exceed limit, please adjust grouping conditions to reduce the number of groups.',
- HttpStatus.PAYLOAD_TOO_LARGE
+ if (search) {
+ const searchFields = await this.recordService.getSearchFields(
+ fieldMap,
+ search,
+ query?.viewId
);
+ const tableIndex = await this.tableIndexService.getActivatedTableIndexes(tableId);
+ queryBuilder.where((builder) => {
+ this.dbProvider.searchQuery(builder, searchFields, tableIndex, search);
+ });
}
-
- this.dbProvider.sortQuery(queryBuilder, fieldInstanceMap, groupBy).appendSortBuilder();
-
- queryBuilder.count({ __c: '*' });
-
- groupFieldIds.forEach((fieldId) => {
- const field = fieldInstanceMap[fieldId];
-
- if (!field) return;
-
- const { dbFieldType, dbFieldName } = field;
- const column =
- dbFieldType === DbFieldType.Json
- ? this.knex.raw(`CAST(?? as text)`, [dbFieldName]).toQuery()
- : this.knex.ref(dbFieldName).toQuery();
-
- queryBuilder.select(this.knex.raw(`${column}`)).groupBy(dbFieldName);
+ this.dbProvider.calendarDailyCollectionQuery(queryBuilder, {
+ startDate,
+ endDate,
+ startField: startField as DateFieldDto,
+ endField: endField as DateFieldDto,
+ dbTableName: viewCte || dbTableName,
});
+ const result = await this.prisma
+ .txClient()
+ .$queryRawUnsafe<
+ { date: Date | string; count: number; ids: string[] | string }[]
+ >(queryBuilder.toQuery());
+
+ const countMap = result.reduce(
+ (map, item) => {
+ const key = isString(item.date) ? item.date : item.date.toISOString().split('T')[0];
+ map[key] = Number(item.count);
+ return map;
+ },
+ {} as Record
+ );
+ let recordIds = result
+ .map((item) => (isString(item.ids) ? item.ids.split(',') : item.ids))
+ .flat();
+ recordIds = Array.from(new Set(recordIds));
+
+ if (!recordIds.length) {
+ return {
+ countMap,
+ records: [],
+ };
+ }
- const groupSql = queryBuilder.toQuery();
-
- const result =
- await this.prisma.$queryRawUnsafe<{ [key: string]: unknown; __c: number }[]>(groupSql);
-
- const groupFields = groupFieldIds.map((fieldId) => fieldInstanceMap[fieldId]);
+ const { records } = await this.recordService.getRecordsById(tableId, recordIds);
- return this.groupDbCollection2GroupPoints(result, groupFields);
+ return {
+ countMap,
+ records,
+ };
}
}
diff --git a/apps/nestjs-backend/src/features/aggregation/index.ts b/apps/nestjs-backend/src/features/aggregation/index.ts
new file mode 100644
index 0000000000..6a77f478ea
--- /dev/null
+++ b/apps/nestjs-backend/src/features/aggregation/index.ts
@@ -0,0 +1,9 @@
+export type {
+ IAggregationService,
+ IWithView,
+ ICustomFieldStats,
+} from './aggregation.service.interface';
+export { AggregationService } from './aggregation.service';
+export { AggregationModule } from './aggregation.module';
+export { AGGREGATION_SERVICE_SYMBOL } from './aggregation.service.symbol';
+export { InjectAggregationService } from './aggregation.service.provider';
diff --git a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.spec.ts b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.spec.ts
index 5e5a2fa236..2cf76e04d1 100644
--- a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.spec.ts
+++ b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.spec.ts
@@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing';
import { PrismaService } from '@teable/db-main-prisma';
import { vi } from 'vitest';
import { AggregationService } from '../aggregation.service';
+import { AGGREGATION_SERVICE_SYMBOL } from '../aggregation.service.symbol';
import { AggregationOpenApiController } from './aggregation-open-api.controller';
import { AggregationOpenApiService } from './aggregation-open-api.service';
@@ -12,7 +13,14 @@ describe('AggregationOpenApiController', () => {
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AggregationOpenApiController],
- providers: [AggregationOpenApiService, AggregationService],
+ providers: [
+ AggregationOpenApiService,
+ AggregationService,
+ {
+ provide: AGGREGATION_SERVICE_SYMBOL,
+ useClass: AggregationService,
+ },
+ ],
})
.useMocker((token) => {
if (token === PrismaService) {
diff --git a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts
index a6b382b5cf..7b0376bbcb 100644
--- a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts
+++ b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.controller.ts
@@ -1,22 +1,102 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Controller, Get, Param, Query } from '@nestjs/common';
-import type { IAggregationVo, IGroupPointsVo, IRowCountVo } from '@teable/core';
+import type { IFilter } from '@teable/core';
+import { PrismaService } from '@teable/db-main-prisma';
+import type {
+ IAggregationVo,
+ ICalendarDailyCollectionVo,
+ IGroupPointsVo,
+ IRowCountVo,
+ ISearchCountVo,
+ ISearchIndexVo,
+ ITaskStatusCollectionVo,
+ IRecordIndexVo,
+} from '@teable/openapi';
import {
aggregationRoSchema,
+ calendarDailyCollectionRoSchema,
groupPointsRoSchema,
IAggregationRo,
IGroupPointsRo,
IQueryBaseRo,
+ searchCountRoSchema,
+ ISearchCountRo,
queryBaseSchema,
-} from '@teable/core';
+ ICalendarDailyCollectionRo,
+ ISearchIndexByQueryRo,
+ searchIndexByQueryRoSchema,
+ IRecordIndexRo,
+ recordIndexRoSchema,
+} from '@teable/openapi';
+import { ClsService } from 'nestjs-cls';
+import { PerformanceCacheService } from '../../../performance-cache';
+import { generateAggCacheKey } from '../../../performance-cache/generate-keys';
+import type { IClsStore } from '../../../types/cls';
+import { filterHasMe } from '../../../utils/filter-has-me';
import { ZodValidationPipe } from '../../../zod.validation.pipe';
+import { AllowAnonymous } from '../../auth/decorators/allow-anonymous.decorator';
import { Permissions } from '../../auth/decorators/permissions.decorator';
import { TqlPipe } from '../../record/open-api/tql.pipe';
import { AggregationOpenApiService } from './aggregation-open-api.service';
@Controller('api/table/:tableId/aggregation')
+@AllowAnonymous()
export class AggregationOpenApiController {
- constructor(private readonly aggregationOpenApiService: AggregationOpenApiService) {}
+ constructor(
+ private readonly aggregationOpenApiService: AggregationOpenApiService,
+ private readonly prismaService: PrismaService,
+ private readonly cls: ClsService,
+ private readonly performanceCacheService: PerformanceCacheService
+ ) {}
+
+ private async getAggregationWithCache(
+ cacheKeyPrefix: string,
+ tableId: string,
+ query: { filter?: IFilter; viewId?: string } | undefined,
+ fn: () => Promise
+ ) {
+ const table = await this.prismaService.tableMeta.findUniqueOrThrow({
+ where: {
+ id: tableId,
+ },
+ select: {
+ lastModifiedTime: true,
+ },
+ });
+ const viewId = query?.viewId;
+ let viewFilter: string | null = null;
+ if (viewId) {
+ const view = await this.prismaService.view.findUniqueOrThrow({
+ where: {
+ id: viewId,
+ },
+ select: {
+ filter: true,
+ },
+ });
+ viewFilter = view.filter;
+ }
+ const cacheQuery =
+ filterHasMe(query?.filter) || filterHasMe(viewFilter)
+ ? { ...query, currentUserId: this.cls.get('user.id') }
+ : query;
+
+ const cacheKey = generateAggCacheKey(
+ cacheKeyPrefix,
+ tableId,
+ table.lastModifiedTime?.getTime().toString() ?? '0',
+ cacheQuery
+ );
+ return this.performanceCacheService.wrap(
+ cacheKey,
+ () => {
+ return fn();
+ },
+ {
+ ttl: 60 * 60, // 1 hour
+ }
+ );
+ }
@Get()
@Permissions('table|read')
@@ -24,7 +104,9 @@ export class AggregationOpenApiController {
@Param('tableId') tableId: string,
@Query(new ZodValidationPipe(aggregationRoSchema), TqlPipe) query?: IAggregationRo
): Promise {
- return await this.aggregationOpenApiService.getAggregation(tableId, query);
+ return await this.getAggregationWithCache('aggregation', tableId, query, () =>
+ this.aggregationOpenApiService.getAggregation(tableId, query)
+ );
}
@Get('/row-count')
@@ -33,7 +115,42 @@ export class AggregationOpenApiController {
@Param('tableId') tableId: string,
@Query(new ZodValidationPipe(queryBaseSchema), TqlPipe) query?: IQueryBaseRo
): Promise {
- return await this.aggregationOpenApiService.getRowCount(tableId, query);
+ return await this.getAggregationWithCache('row_count', tableId, query, () =>
+ this.aggregationOpenApiService.getRowCount(tableId, query)
+ );
+ }
+
+ @Get('/record-index')
+ @Permissions('table|read')
+ async getRecordIndex(
+ @Param('tableId') tableId: string,
+ @Query(new ZodValidationPipe(recordIndexRoSchema), TqlPipe) query: IRecordIndexRo
+ ): Promise {
+ return await this.getAggregationWithCache('record_index', tableId, query, () =>
+ this.aggregationOpenApiService.getRecordIndex(tableId, query)
+ );
+ }
+
+ @Get('/search-count')
+ @Permissions('table|read')
+ async getSearchCount(
+ @Param('tableId') tableId: string,
+ @Query(new ZodValidationPipe(searchCountRoSchema), TqlPipe) query: ISearchCountRo
+ ): Promise {
+ return await this.getAggregationWithCache('search_count', tableId, query, () =>
+ this.aggregationOpenApiService.getSearchCount(tableId, query)
+ );
+ }
+
+ @Get('/search-index')
+ @Permissions('table|read')
+ async getSearchIndex(
+ @Param('tableId') tableId: string,
+ @Query(new ZodValidationPipe(searchIndexByQueryRoSchema), TqlPipe) query: ISearchIndexByQueryRo
+ ): Promise {
+ return await this.getAggregationWithCache('search_index', tableId, query, () =>
+ this.aggregationOpenApiService.getRecordIndexBySearchOrder(tableId, query)
+ );
}
@Get('/group-points')
@@ -42,6 +159,31 @@ export class AggregationOpenApiController {
@Param('tableId') tableId: string,
@Query(new ZodValidationPipe(groupPointsRoSchema), TqlPipe) query?: IGroupPointsRo
): Promise {
- return await this.aggregationOpenApiService.getGroupPoints(tableId, query);
+ return await this.getAggregationWithCache('group_points', tableId, query, () =>
+ this.aggregationOpenApiService.getGroupPoints(tableId, query, true)
+ );
+ }
+
+ @Get('/calendar-daily-collection')
+ @Permissions('table|read')
+ async getCalendarDailyCollection(
+ @Param('tableId') tableId: string,
+ @Query(new ZodValidationPipe(calendarDailyCollectionRoSchema), TqlPipe)
+ query: ICalendarDailyCollectionRo
+ ): Promise {
+ return await this.getAggregationWithCache('calendar_daily_collection', tableId, query, () =>
+ this.aggregationOpenApiService.getCalendarDailyCollection(tableId, query)
+ );
+ }
+
+ @Get('/task-status-collection')
+ @Permissions('table|read')
+ async getTaskStatusCollection(
+ @Param('tableId') _tableId: string
+ ): Promise {
+ return {
+ fieldMap: {},
+ cells: [],
+ };
}
}
diff --git a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts
index 7cf5f66987..acd087e205 100644
--- a/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts
+++ b/apps/nestjs-backend/src/features/aggregation/open-api/aggregation-open-api.service.ts
@@ -1,26 +1,45 @@
import { BadRequestException, Injectable } from '@nestjs/common';
+import type { StatisticsFunc } from '@teable/core';
+import { getValidStatisticFunc } from '@teable/core';
import type {
+ ISearchIndexByQueryRo,
IAggregationRo,
IAggregationVo,
+ ICalendarDailyCollectionRo,
+ ICalendarDailyCollectionVo,
IGroupPointsRo,
IGroupPointsVo,
IQueryBaseRo,
IRowCountVo,
- StatisticsFunc,
-} from '@teable/core';
-import { getValidStatisticFunc } from '@teable/core';
+ ISearchCountRo,
+ IRecordIndexRo,
+ IRecordIndexVo,
+} from '@teable/openapi';
import { forIn, isEmpty, map } from 'lodash';
-import type { IWithView } from '../aggregation.service';
-import { AggregationService } from '../aggregation.service';
+import { IAggregationService } from '../aggregation.service.interface';
+import type { IWithView } from '../aggregation.service.interface';
+import { InjectAggregationService } from '../aggregation.service.provider';
@Injectable()
export class AggregationOpenApiService {
- constructor(private readonly aggregationService: AggregationService) {}
+ constructor(
+ @InjectAggregationService() private readonly aggregationService: IAggregationService
+ ) {}
async getAggregation(tableId: string, query?: IAggregationRo): Promise {
- const { viewId, filter: customFilter, field: aggregationFields } = query || {};
+ const {
+ viewId,
+ filter: customFilter,
+ field: aggregationFields,
+ groupBy,
+ ignoreViewQuery,
+ } = query || {};
- let withView: IWithView = { viewId, customFilter };
+ let withView: IWithView = {
+ viewId: ignoreViewQuery ? undefined : viewId,
+ customFilter,
+ groupBy,
+ };
const fieldStatistics: Array<{ fieldId: string; statisticFunc: StatisticsFunc }> = [];
@@ -41,6 +60,8 @@ export class AggregationOpenApiService {
const result = await this.aggregationService.performAggregation({
tableId: tableId,
withView,
+ search: query?.search,
+ useQueryModel: true,
});
return { aggregations: result?.aggregations };
}
@@ -52,8 +73,23 @@ export class AggregationOpenApiService {
};
}
- async getGroupPoints(tableId: string, query?: IGroupPointsRo): Promise {
- return await this.aggregationService.getGroupPoints(tableId, query);
+ async getGroupPoints(
+ tableId: string,
+ query?: IGroupPointsRo,
+ useQueryModel = true
+ ): Promise {
+ return await this.aggregationService.getGroupPoints(tableId, query, useQueryModel);
+ }
+
+ async getCalendarDailyCollection(
+ tableId: string,
+ query: ICalendarDailyCollectionRo
+ ): Promise {
+ return await this.aggregationService.getCalendarDailyCollection(tableId, query);
+ }
+
+ async getRecordIndex(tableId: string, query: IRecordIndexRo): Promise {
+ return await this.aggregationService.getRecordIndex(tableId, query);
}
private async validFieldStats(
@@ -85,4 +121,16 @@ export class AggregationOpenApiService {
});
return result;
}
+
+ public async getSearchCount(tableId: string, queryRo: ISearchCountRo, projection?: string[]) {
+ return await this.aggregationService.getSearchCount(tableId, queryRo, projection);
+ }
+
+ public async getRecordIndexBySearchOrder(
+ tableId: string,
+ queryRo: ISearchIndexByQueryRo,
+ projection?: string[]
+ ) {
+ return await this.aggregationService.getRecordIndexBySearchOrder(tableId, queryRo, projection);
+ }
}
diff --git a/apps/nestjs-backend/src/features/ai/ai.controller.ts b/apps/nestjs-backend/src/features/ai/ai.controller.ts
new file mode 100644
index 0000000000..393d2b24cf
--- /dev/null
+++ b/apps/nestjs-backend/src/features/ai/ai.controller.ts
@@ -0,0 +1,34 @@
+import { Body, Controller, Get, Param, Post, Res } from '@nestjs/common';
+import { aiGenerateRoSchema, IAiGenerateRo } from '@teable/openapi';
+import { Response } from 'express';
+import { ZodValidationPipe } from '../../zod.validation.pipe';
+import { Permissions } from '../auth/decorators/permissions.decorator';
+import { TablePipe } from '../table/open-api/table.pipe';
+import { AiService } from './ai.service';
+
+@Controller('api/:baseId/ai')
+export class AiController {
+ constructor(private readonly aiService: AiService) {}
+
+ @Post('/generate-stream')
+ @Permissions('base|read')
+ async generateStream(
+ @Param('baseId') baseId: string,
+ @Body(new ZodValidationPipe(aiGenerateRoSchema), TablePipe) aiGenerateRo: IAiGenerateRo,
+ @Res() res: Response
+ ) {
+ await this.aiService.generateStream(baseId, aiGenerateRo, res);
+ }
+
+ @Get('/config')
+ @Permissions('base|read')
+ async getAIConfig(@Param('baseId') baseId: string) {
+ return await this.aiService.getSimplifiedAIConfig(baseId);
+ }
+
+ @Get('/disable-ai-actions')
+ @Permissions('base|read')
+ async getAIDisableAIActions(@Param('baseId') baseId: string) {
+ return await this.aiService.getAIDisableAIActions(baseId);
+ }
+}
diff --git a/apps/nestjs-backend/src/features/ai/ai.module.ts b/apps/nestjs-backend/src/features/ai/ai.module.ts
new file mode 100644
index 0000000000..4dbecc4c87
--- /dev/null
+++ b/apps/nestjs-backend/src/features/ai/ai.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { SettingModule } from '../setting/setting.module';
+import { AiController } from './ai.controller';
+import { AiService } from './ai.service';
+
+@Module({
+ imports: [SettingModule],
+ controllers: [AiController],
+ providers: [AiService],
+ exports: [AiService],
+})
+export class AiModule {}
diff --git a/apps/nestjs-backend/src/features/ai/ai.service.ts b/apps/nestjs-backend/src/features/ai/ai.service.ts
new file mode 100644
index 0000000000..833a32aa0a
--- /dev/null
+++ b/apps/nestjs-backend/src/features/ai/ai.service.ts
@@ -0,0 +1,832 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import type { OpenAIProvider } from '@ai-sdk/openai';
+import { Injectable, Logger } from '@nestjs/common';
+import { HttpErrorCode } from '@teable/core';
+import { PrismaService } from '@teable/db-main-prisma';
+import {
+ IntegrationType,
+ LLMProviderType,
+ SettingKey,
+ Task,
+ convertGatewayApiModel,
+ normalizeGatewayPricing,
+} from '@teable/openapi';
+import type {
+ IAIConfig,
+ IAiGenerateRo,
+ IChatModelAbility,
+ IGatewayApiModel,
+ IGatewayApiModelRaw,
+ IGetAIConfig,
+ GatewayModelTag,
+ LLMProvider,
+} from '@teable/openapi';
+import type { ImageModel, LanguageModel } from 'ai';
+import { createGateway, generateText, streamText } from 'ai';
+import axios from 'axios';
+import type { Response } from 'express';
+import { BaseConfig, IBaseConfig } from '../../configs/base.config';
+import { CustomHttpException } from '../../custom.exception';
+import { PerformanceCacheService } from '../../performance-cache';
+import { SettingService } from '../setting/setting.service';
+import { getAdaptedProviderOptions, getTaskModelKey, modelProviders } from './util';
+
+// Fixed name for AI Gateway provider in modelKey (format: aiGateway@@teable)
+export const AI_GATEWAY_PROVIDER_NAME = 'teable';
+
+export type ILanguageModelV2 = Exclude;
+
+// In-memory cache for Gateway models (TTL: 10 minutes)
+const gatewayModelsCacheTtl = 10 * 60 * 1000;
+
+interface IGatewayModelsCache {
+ data: IGatewayApiModel[];
+ expiresAt: number;
+}
+
+@Injectable()
+export class AiService {
+ private readonly logger = new Logger(AiService.name);
+
+ // In-memory cache for Gateway models API - faster than Redis for static data
+ private gatewayModelsCache: IGatewayModelsCache | null = null;
+
+ constructor(
+ private readonly settingService: SettingService,
+ private readonly prismaService: PrismaService,
+ @BaseConfig() private readonly baseConfig: IBaseConfig,
+ private readonly performanceCacheService: PerformanceCacheService
+ ) {}
+
+ public parseModelKey(modelKey: string) {
+ const [type, model, name] = modelKey.split('@');
+ return { type, model, name };
+ }
+
+ /**
+ * Resolve the model key by matching a body model ID against chatModel lg/md/sm values.
+ * Model keys are in format type@modelId@name — we compare the modelId segment.
+ * Falls back to lg if no match is found.
+ */
+ public resolveModelKeyFromBody(
+ chatModel: { lg?: string; md?: string; sm?: string } | undefined,
+ bodyModel?: string
+ ): string | undefined {
+ if (bodyModel) {
+ const sizes = ['lg', 'md', 'sm'] as const;
+ for (const size of sizes) {
+ const key = chatModel?.[size];
+ if (key && this.parseModelKey(key).model === bodyModel) {
+ return key;
+ }
+ }
+ }
+ return chatModel?.lg;
+ }
+
+ /**
+ * Check if modelKey is an AI Gateway model
+ * Format: aiGateway@@teable
+ */
+ public isGatewayModel(modelKey: string): boolean {
+ const { type, name } = this.parseModelKey(modelKey);
+ return (
+ type?.toLowerCase() === LLMProviderType.AI_GATEWAY.toLowerCase() &&
+ name?.toLowerCase() === AI_GATEWAY_PROVIDER_NAME.toLowerCase()
+ );
+ }
+
+ /**
+ * Build a gateway modelKey from a gateway model ID
+ * @param modelId Gateway model ID (e.g., "anthropic/claude-sonnet-4")
+ */
+ public buildGatewayModelKey(modelId: string): string {
+ return `${LLMProviderType.AI_GATEWAY}@${modelId}@${AI_GATEWAY_PROVIDER_NAME}`;
+ }
+
+ /**
+ * Parse owner/provider from gateway model ID
+ * @param modelId Gateway model ID (e.g., "anthropic/claude-sonnet-4" -> "anthropic")
+ */
+ private parseOwnerFromModelId(modelId: string): string | undefined {
+ const parts = modelId.split('/');
+ return parts.length > 1 ? parts[0].toLowerCase() : undefined;
+ }
+
+ // modelKey-> type@model@name
+ async getModelConfig(modelKey: string, llmProviders: LLMProvider[] = []) {
+ const { type, model, name } = this.parseModelKey(modelKey);
+
+ // Special handling for AI Gateway models
+ if (this.isGatewayModel(modelKey)) {
+ const { aiConfig } = await this.settingService.getSetting([SettingKey.AI_CONFIG]);
+
+ if (!aiConfig?.aiGatewayApiKey) {
+ throw new CustomHttpException(
+ 'AI Gateway API key is not configured',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.ai.gatewayApiKeyNotSet',
+ },
+ }
+ );
+ }
+
+ return {
+ type: LLMProviderType.AI_GATEWAY,
+ model, // This is the gateway modelId (e.g., "anthropic/claude-sonnet-4")
+ baseUrl: aiConfig.aiGatewayBaseUrl || undefined,
+ apiKey: aiConfig.aiGatewayApiKey,
+ };
+ }
+
+ // Standard provider lookup
+ const providerConfig = llmProviders.find(
+ (p) =>
+ p.name.toLowerCase() === name.toLowerCase() && p.type.toLowerCase() === type.toLowerCase()
+ );
+
+ if (!providerConfig) {
+ throw new CustomHttpException(
+ 'AI provider configuration is not set',
+ HttpErrorCode.VALIDATION_ERROR,
+ {
+ localization: {
+ i18nKey: 'httpErrors.ai.providerConfigurationNotSet',
+ },
+ }
+ );
+ }
+
+ const { baseUrl, apiKey } = providerConfig;
+
+ return {
+ type,
+ model,
+ baseUrl,
+ apiKey,
+ };
+ }
+
+ async getModelInstance(
+ modelKey: string,
+ llmProviders: LLMProvider[],
+ isImageGeneration: true
+ ): Promise