Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ on:
branches: [main]

jobs:
ui:
name: UI typecheck and build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- run: pnpm --dir ui install --frozen-lockfile
- run: pnpm --dir ui typecheck
- run: pnpm --dir ui build

test:
name: Test and lint
runs-on: ubuntu-latest
Expand All @@ -18,7 +35,6 @@ jobs:
- run: cargo test --all-targets
- run: cargo clippy --all-targets -- -D warnings
- run: cargo fmt --all -- --check
- run: node --check ui/connection-fields.js

validate-manifest:
name: Validate .tabularium manifest
Expand All @@ -35,4 +51,3 @@ jobs:
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}

35 changes: 30 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,30 @@ on:
- "v*"

jobs:
ui:
name: UI bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- run: pnpm --dir ui install --frozen-lockfile
- run: pnpm --dir ui typecheck
- run: pnpm --dir ui build
- uses: actions/upload-artifact@v7
with:
name: ui-dist
path: ui/dist/

build:
name: ${{ matrix.platform-label }}
needs: ui
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
Expand Down Expand Up @@ -48,22 +70,26 @@ jobs:
- name: Build
if: ${{ !matrix.cross }}
run: cargo build --release --target ${{ matrix.target }}
- uses: actions/download-artifact@v8
with:
name: ui-dist
path: ui/dist/
- name: Package on Unix
if: runner.os != 'Windows'
run: |
mkdir -p staging/ui
mkdir -p staging/ui/dist
cp target/${{ matrix.target }}/release/bigquery-plugin${{ matrix.binary-suffix }} staging/
cp .tabularium staging/
cp ui/connection-fields.js staging/ui/
cp ui/dist/*.js staging/ui/dist/
(cd staging && zip -r ../bigquery-plugin-${{ matrix.platform-label }}.zip .)
- name: Package on Windows
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path "staging\ui" | Out-Null
New-Item -ItemType Directory -Force -Path "staging\ui\dist" | Out-Null
Copy-Item "target\${{ matrix.target }}\release\bigquery-plugin${{ matrix.binary-suffix }}" staging
Copy-Item ".tabularium" staging
Copy-Item "ui\connection-fields.js" "staging\ui"
Copy-Item "ui\dist\*.js" "staging\ui\dist"
Compress-Archive -Path "staging\*" -DestinationPath "bigquery-plugin-${{ matrix.platform-label }}.zip"
- name: Smoke test on Unix
if: runner.os != 'Windows' && !matrix.cross
Expand Down Expand Up @@ -100,4 +126,3 @@ jobs:
files: |
artifacts/*.zip
.tabularium

3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/target/
ui/node_modules/
ui/dist/
*.log
.DS_Store
.idea/
.vscode/

12 changes: 11 additions & 1 deletion .tabularium
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@
"executable": "bigquery-plugin",
"color": "#4285F4",
"icon": "cloud",
"connection_fields": {
"host": {"hidden": true},
"port": {"hidden": true},
"username": {"hidden": true},
"password": {"hidden": true},
"database": {
"label": "GCP Project ID",
"placeholder": "billing-project"
}
},
"ui_extensions": [
{
"slot": "connection-modal.extra_fields",
"module": "ui/connection-fields.js",
"module": "ui/dist/connection-fields.js",
"order": 10,
"driver": "bigquery"
}
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project are documented in this file.

## Unreleased

- Hide unused common connection fields on compatible Tabularis hosts.
- Store plugin-owned credentials in the OS keychain while preserving legacy Password-based connections.
- Author the connection UI in React and TypeScript and generate its release bundle with Vite.

## 0.1.0

- Add the initial BigQuery REST API v2 driver implementation.
Expand All @@ -10,4 +16,3 @@ All notable changes to this project are documented in this file.
- Add GoogleSQL query, batch, CRUD, DDL, BLOB and Visual EXPLAIN support.
- Add the Tabularis connection UI extension and Tabularium manifest.
- Add cross-platform CI and release packaging.

30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,12 @@ A native Google BigQuery driver for [Tabularis](https://github.com/TabularisDB/t

## Connection setup

Tabularis currently provides common database fields plus a plugin extension area. The BigQuery plugin maps them as follows:
On a compatible Tabularis host, the BigQuery plugin hides unused common fields and maps the connection form as follows:

| Tabularis field | BigQuery meaning |
| --- | --- |
| Database | GCP Project ID used for jobs and billing |
| Password | Service account JSON/path or access token, depending on Authentication |
| Host | Ignored |
| Port | Ignored |
| Username | Ignored |
| GCP Project ID | Project used for jobs and billing |
| Credential | Service account JSON/path, authorized user JSON/path, or access token |

The BigQuery section in the connection form adds:

Expand All @@ -41,13 +38,13 @@ The BigQuery section in the connection form adds:
- **Query timeout**: 30 to 3600 seconds, default 300
- **Maximum bytes billed**: optional per-query cost guard

Enable Tabularis keychain storage when the Password field contains a service account key or access token.
The Credential field is plugin-owned. Tabularis stores its value in the OS keychain and never writes it to `connections.json`. Existing connections that used the Password field remain supported and do not require migration.

Native secure plugin-owned fields and hiding unused common fields are tracked upstream in [Tabularis issue #623](https://github.com/TabularisDB/tabularis/issues/623).
Older Tabularis hosts ignore the field presentation overrides. On those hosts, continue to use Password for credentials and enable keychain storage.

### Application Default Credentials

Leave Password empty. The plugin tries the standard Google credential chain:
Leave Credential empty. The plugin tries the standard Google credential chain:

1. `GOOGLE_APPLICATION_CREDENTIALS`
2. Credentials created by `gcloud auth application-default login`
Expand All @@ -56,15 +53,15 @@ Leave Password empty. The plugin tries the standard Google credential chain:

### Service account

Select **Service account JSON** and put either the credential file path or the complete JSON document in Password. A file path is preferred because Tabularis never needs to persist the private key itself.
Select **Service account JSON** and put either the credential file path or the complete JSON document in Credential.

### OAuth access token

Select **OAuth access token** and put a current bearer token in Password. Direct access tokens expire and the plugin cannot refresh them. ADC is recommended for interactive user credentials.
Select **OAuth access token** and put a current bearer token in Credential. Direct access tokens expire and the plugin cannot refresh them. ADC is recommended for interactive user credentials.

### OAuth authorized user

Select **OAuth authorized user JSON** and put either an `authorized_user` credential file path or the complete JSON document in Password. The document must contain `client_id`, `client_secret` and `refresh_token`. The plugin exchanges the refresh token only with Google's fixed OAuth token endpoint and caches short-lived access tokens.
Select **OAuth authorized user JSON** and put either an `authorized_user` credential file path or the complete JSON document in Credential. The document must contain `client_id`, `client_secret` and `refresh_token`. The plugin exchanges the refresh token only with Google's fixed OAuth token endpoint and caches short-lived access tokens.

## Required Google Cloud permissions

Expand All @@ -79,7 +76,8 @@ bigquery/
├── .tabularium
├── bigquery-plugin
└── ui/
└── connection-fields.js
└── dist/
└── connection-fields.js
```

Use `bigquery-plugin.exe` on Windows.
Expand All @@ -92,14 +90,16 @@ Use `bigquery-plugin.exe` on Windows.

## Build and test

Rust stable is required.
Rust stable, Node.js 20 and pnpm 9 are required. The UI extension is authored in React and TypeScript under `ui/src`; Vite produces the IIFE bundle loaded by Tabularis.

```bash
pnpm --dir ui install --frozen-lockfile
pnpm --dir ui typecheck
pnpm --dir ui build
cargo build
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --check
node --check ui/connection-fields.js
```

Validate the release manifest with Tabularium:
Expand Down
25 changes: 17 additions & 8 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ build:
release:
cargo build --release

ui-install:
pnpm --dir ui install

ui-build:
pnpm --dir ui build

ui-typecheck:
pnpm --dir ui typecheck

test:
cargo test --all-targets

Expand All @@ -17,19 +26,19 @@ fmt:
cargo fmt --all

check-ui:
node --check ui/connection-fields.js
pnpm --dir ui typecheck
pnpm --dir ui build

[linux]
dev-install: build
mkdir -p ~/.local/share/tabularis/plugins/bigquery/ui
dev-install: build ui-build
mkdir -p ~/.local/share/tabularis/plugins/bigquery/ui/dist
cp target/debug/bigquery-plugin ~/.local/share/tabularis/plugins/bigquery/
cp .tabularium ~/.local/share/tabularis/plugins/bigquery/
cp ui/connection-fields.js ~/.local/share/tabularis/plugins/bigquery/ui/
cp ui/dist/*.js ~/.local/share/tabularis/plugins/bigquery/ui/dist/

[macos]
dev-install: build
mkdir -p "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/ui"
dev-install: build ui-build
mkdir -p "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/ui/dist"
cp target/debug/bigquery-plugin "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/"
cp .tabularium "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/"
cp ui/connection-fields.js "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/ui/"

cp ui/dist/*.js "$HOME/Library/Application Support/com.debba.tabularis/plugins/bigquery/ui/dist/"
27 changes: 8 additions & 19 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,9 @@ impl AuthProvider {
.map_err(|error| format!("Application Default Credentials are unavailable: {error}")),
"service_account" => {
let credential = params
.password
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.credential()
.ok_or_else(|| {
"Service account authentication requires a JSON key or file path in Password"
"Service account authentication requires a JSON key or file path"
.to_string()
})?;
let account = if credential.starts_with('{') {
Expand All @@ -66,7 +63,7 @@ impl AuthProvider {
Ok(Self::Google(Arc::new(account)))
}
"authorized_user" => {
let credential = read_password_credential(params).await?;
let credential = read_json_credential(params).await?;
let credentials: AuthorizedUserCredentials = serde_json::from_str(&credential)
.map_err(|error| format!("Invalid authorized user credential JSON: {error}"))?;
if credentials.credential_type != "authorized_user" {
Expand All @@ -93,12 +90,9 @@ impl AuthProvider {
})))
}
"access_token" => params
.password
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.credential()
.map(|value| Self::AccessToken(value.to_string()))
.ok_or_else(|| "Access token authentication requires a token in Password".to_string()),
.ok_or_else(|| "Access token authentication requires a token".to_string()),
method => Err(format!(
"Unsupported authentication method '{method}'. Use adc, service_account, authorized_user, or access_token"
)),
Expand Down Expand Up @@ -169,15 +163,10 @@ impl AuthorizedUserProvider {
}
}

async fn read_password_credential(params: &ConnectionParams) -> Result<String, String> {
async fn read_json_credential(params: &ConnectionParams) -> Result<String, String> {
let credential = params
.password
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
"Authentication requires credential JSON or a file path in Password".to_string()
})?;
.credential()
.ok_or_else(|| "Authentication requires credential JSON or a file path".to_string())?;
if credential.starts_with('{') {
Ok(credential.to_string())
} else {
Expand Down
31 changes: 31 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ impl ConnectionParams {
self.extra_value("auth_method").unwrap_or("adc")
}

pub fn credential(&self) -> Option<&str> {
self.extra_value("credential").or_else(|| {
self.password
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
})
}

pub fn location(&self) -> Option<&str> {
self.extra_value("location")
}
Expand Down Expand Up @@ -131,4 +140,26 @@ mod tests {
let value = json!({"database": "p", "extra": {"query_timeout_seconds": "2"}});
assert_eq!(ConnectionParams::from_value(&value).timeout_seconds(), 30);
}

#[test]
fn secure_plugin_credential_takes_priority_over_legacy_password() {
let value = json!({
"database": "p",
"password": "legacy",
"extra": {"credential": "secure"}
});
assert_eq!(
ConnectionParams::from_value(&value).credential(),
Some("secure")
);
}

#[test]
fn legacy_password_remains_a_credential_fallback() {
let value = json!({"database": "p", "password": "legacy"});
assert_eq!(
ConnectionParams::from_value(&value).credential(),
Some("legacy")
);
}
}
Loading
Loading