mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-03 03:51:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9235292586 | ||
|
|
23f10387c5 | ||
|
|
0d3ad9e4fd | ||
|
|
f5d232d971 |
@@ -1,6 +1,3 @@
|
||||
# This workflow will build a golang project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||
|
||||
name: Create Go Release (Tag Versioning)
|
||||
|
||||
on:
|
||||
@@ -26,7 +23,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
@@ -38,7 +37,7 @@ jobs:
|
||||
run: |
|
||||
git fetch --tags
|
||||
latest_tag=$(git describe --tags `git rev-list --tags --max-count=1`)
|
||||
echo "::set-output name=tag::$latest_tag"
|
||||
echo "tag=${latest_tag}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Determine new tag version
|
||||
id: new_tag
|
||||
@@ -57,7 +56,7 @@ jobs:
|
||||
((minor++))
|
||||
patch=0
|
||||
;;
|
||||
"release")
|
||||
"major")
|
||||
((major++))
|
||||
minor=0
|
||||
patch=0
|
||||
@@ -68,15 +67,11 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
new_tag="v$major.$minor.$patch"
|
||||
echo "::set-output name=tag::$new_tag"
|
||||
echo "tag=${new_tag}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Create tag
|
||||
run: |
|
||||
git tag -a ${{ steps.new_tag.outputs.tag }} -m "Tagging ${{ steps.new_tag.outputs.tag }} for release"
|
||||
|
||||
- name: Push changes
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.BITECH_GITHUB_TOKEN }}
|
||||
force: true
|
||||
tags: true
|
||||
- name: Push tag
|
||||
run: git push origin ${{ steps.new_tag.outputs.tag }}
|
||||
@@ -0,0 +1,268 @@
|
||||
name: Release Clients
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Client version (e.g. 1.4.0)"
|
||||
required: true
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish packages to Gitea (untick for a build/test dry run)"
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
VERSION_INPUT: ${{ github.event.inputs.version }}
|
||||
PUBLISH: ${{ github.event.inputs.publish }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REGISTRY_USER: ${{ secrets.PACKAGE_REGISTRY_USERNAME || vars.PACKAGE_REGISTRY_USERNAME }}
|
||||
TOKEN: ${{ secrets.PACKAGE_REGISTRY_TOKEN || vars.PACKAGE_REGISTRY_TOKEN }}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
steps:
|
||||
- id: v
|
||||
run: |
|
||||
version="${VERSION_INPUT#v}"
|
||||
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid version: $VERSION_INPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
js:
|
||||
name: JS (npm)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: clients/resolvespec-js
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Enable pnpm
|
||||
run: corepack enable
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Set version
|
||||
run: npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
if: ${{ env.PUBLISH == 'true' }}
|
||||
run: |
|
||||
host="${SERVER_URL#*://}"
|
||||
registry="${SERVER_URL}/api/packages/${OWNER}/npm/"
|
||||
npm config set "@warkypublic:registry" "$registry"
|
||||
npm config set "//${host}/api/packages/${OWNER}/npm/:_authToken" "$TOKEN"
|
||||
npm publish --registry "$registry"
|
||||
|
||||
python:
|
||||
name: Python (PyPI)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: clients/resolvespec-python
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install
|
||||
run: pip install -e ".[dev]" build twine
|
||||
|
||||
- name: Test
|
||||
run: pytest
|
||||
|
||||
- name: Set version
|
||||
run: sed -i -E "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
|
||||
|
||||
- name: Build
|
||||
run: python -m build
|
||||
|
||||
- name: Publish
|
||||
if: ${{ env.PUBLISH == 'true' }}
|
||||
run: |
|
||||
twine upload \
|
||||
--repository-url "${SERVER_URL}/api/packages/${OWNER}/pypi" \
|
||||
-u "$REGISTRY_USER" -p "$TOKEN" \
|
||||
dist/*
|
||||
|
||||
rust:
|
||||
name: Rust (Cargo)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: clients/resolvespec-rs
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Test
|
||||
run: cargo test
|
||||
|
||||
- name: Set version
|
||||
run: sed -i -E '0,/^version = ".*"/s//version = "'"${VERSION}"'"/' Cargo.toml
|
||||
|
||||
- name: Package
|
||||
run: cargo package --allow-dirty
|
||||
|
||||
- name: Publish
|
||||
if: ${{ env.PUBLISH == 'true' }}
|
||||
env:
|
||||
CARGO_REGISTRIES_GITEA_INDEX: sparse+${{ github.server_url }}/api/packages/${{ github.repository_owner }}/cargo/
|
||||
run: |
|
||||
export CARGO_REGISTRIES_GITEA_TOKEN="Bearer ${TOKEN}"
|
||||
cargo publish --registry gitea --allow-dirty
|
||||
|
||||
dotnet:
|
||||
name: C# (NuGet)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: clients/resolvespec-cs
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
|
||||
- name: Test
|
||||
run: dotnet test tests/ResolveSpec.Tests.csproj
|
||||
|
||||
- name: Pack
|
||||
run: dotnet pack src/ResolveSpec.csproj -c Release -p:Version="$VERSION" -o out
|
||||
|
||||
- name: Publish
|
||||
if: ${{ env.PUBLISH == 'true' }}
|
||||
run: |
|
||||
dotnet nuget push out/*.nupkg \
|
||||
--source "${SERVER_URL}/api/packages/${OWNER}/nuget/index.json" \
|
||||
--api-key "$TOKEN" \
|
||||
--skip-duplicate
|
||||
|
||||
go:
|
||||
name: Go (Go registry)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: clients/resolvespec-go
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: clients/resolvespec-go/go.mod
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: Build module zip
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os, re, zipfile
|
||||
version = "v" + os.environ["VERSION"]
|
||||
module = re.search(r"^module\s+(\S+)", open("go.mod").read(), re.M).group(1)
|
||||
prefix = f"{module}@{version}/"
|
||||
with zipfile.ZipFile("../resolvespec-go.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for root, dirs, files in os.walk("."):
|
||||
dirs[:] = [d for d in dirs if d != ".git"]
|
||||
for f in files:
|
||||
path = os.path.join(root, f)
|
||||
z.write(path, prefix + os.path.relpath(path, "."))
|
||||
PY
|
||||
|
||||
- name: Publish
|
||||
if: ${{ env.PUBLISH == 'true' }}
|
||||
run: |
|
||||
curl -f -X PUT \
|
||||
--user "${REGISTRY_USER}:${TOKEN}" \
|
||||
--upload-file ../resolvespec-go.zip \
|
||||
"${SERVER_URL}/api/packages/${OWNER}/go/upload"
|
||||
|
||||
dart:
|
||||
name: Dart (Pub)
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: clients/resolvespec-dart
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
|
||||
- name: Install
|
||||
run: dart pub get
|
||||
|
||||
- name: Analyze
|
||||
run: dart analyze
|
||||
|
||||
- name: Test
|
||||
run: dart test
|
||||
|
||||
- name: Set version and registry
|
||||
run: |
|
||||
sed -i -E "s/^version: .*/version: ${VERSION}/" pubspec.yaml
|
||||
sed -i -E "s#^publish_to: .*#publish_to: ${SERVER_URL}/api/packages/${OWNER}/pub#" pubspec.yaml
|
||||
if ! grep -q "^## ${VERSION}\$" CHANGELOG.md; then
|
||||
{ head -n 1 CHANGELOG.md; printf '\n## %s\n\n- Release %s.\n' "$VERSION" "$VERSION"; tail -n +2 CHANGELOG.md; } > CHANGELOG.tmp
|
||||
mv CHANGELOG.tmp CHANGELOG.md
|
||||
fi
|
||||
# pub warns about a dirty git tree; commit the stamped files locally (never pushed)
|
||||
git -c user.name=ci -c user.email=ci@localhost commit -q -am "ci: stamp dart version ${VERSION}"
|
||||
|
||||
- name: Dry run
|
||||
if: ${{ env.PUBLISH != 'true' }}
|
||||
run: dart pub publish --dry-run
|
||||
|
||||
- name: Publish
|
||||
if: ${{ env.PUBLISH == 'true' }}
|
||||
run: |
|
||||
dart pub token add "${SERVER_URL}/api/packages/${OWNER}/pub" --env-var TOKEN
|
||||
dart pub publish --force
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: Run unit tests
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: coverage-report
|
||||
@@ -31,15 +31,16 @@ jobs:
|
||||
name: Race Detector
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: Run unit tests with the race detector
|
||||
run: go test -race -count=1 ./pkg/...
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
if: false # disabled for now
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
@@ -56,46 +57,51 @@ jobs:
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: Install PostgreSQL client
|
||||
run: |
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq postgresql-client
|
||||
- name: Create test databases
|
||||
env:
|
||||
PGPASSWORD: postgres
|
||||
run: |
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE resolvespec_test;"
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE restheadspec_test;"
|
||||
psql -h postgres -U postgres -c "CREATE DATABASE resolvespec_test;"
|
||||
psql -h postgres -U postgres -c "CREATE DATABASE restheadspec_test;"
|
||||
- name: Run resolvespec integration tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
|
||||
TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
|
||||
run: go test -tags=integration ./pkg/resolvespec -v -coverprofile=coverage-resolvespec-integration.out
|
||||
- name: Run restheadspec integration tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=restheadspec_test port=5432 sslmode=disable"
|
||||
TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=restheadspec_test port=5432 sslmode=disable"
|
||||
run: go test -tags=integration ./pkg/restheadspec -v -coverprofile=coverage-restheadspec-integration.out
|
||||
- name: Generate integration coverage
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
|
||||
TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable"
|
||||
run: |
|
||||
go tool cover -html=coverage-resolvespec-integration.out -o coverage-resolvespec-integration.html
|
||||
go tool cover -html=coverage-restheadspec-integration.out -o coverage-restheadspec-integration.html
|
||||
|
||||
- name: Upload resolvespec integration coverage
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: resolvespec-integration-coverage-report
|
||||
path: coverage-resolvespec-integration.html
|
||||
|
||||
- name: Upload restheadspec integration coverage
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
|
||||
with:
|
||||
name: integration-coverage-restheadspec-report
|
||||
path: coverage-restheadspec-integration
|
||||
path: coverage-restheadspec-integration.html
|
||||
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial release: ResolveSpec (JSON body) and FunctionSpec client.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hein
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,7 @@
|
||||
name: resolvespec
|
||||
description: Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
|
||||
version: 0.1.0
|
||||
repository: https://git.warky.dev/wdevs/ResolveSpec
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# resolvespec-go
|
||||
|
||||
Go client for ResolveSpec (JSON body) and FunctionSpec. Module: `github.com/bitechdev/ResolveSpec/clients/resolvespec-go`. Stdlib only.
|
||||
Go client for ResolveSpec (JSON body) and FunctionSpec. Module: `git.warky.dev/wdevs/ResolveSpec/clients/resolvespec-go`. Stdlib only.
|
||||
|
||||
## Clients
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module github.com/bitechdev/ResolveSpec/clients/resolvespec-go
|
||||
module git.warky.dev/wdevs/ResolveSpec/clients/resolvespec-go
|
||||
|
||||
go 1.22
|
||||
|
||||
@@ -1507,6 +1507,13 @@ func (b *BunInsertQuery) OnConflict(action string) common.InsertQuery {
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BunInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
|
||||
if len(columns) > 0 {
|
||||
b.query = b.query.ExcludeColumn(columns...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BunInsertQuery) Returning(columns ...string) common.InsertQuery {
|
||||
if len(columns) > 0 {
|
||||
b.query = b.query.Returning(strings.Join(columns, ", "))
|
||||
@@ -1619,6 +1626,13 @@ func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuer
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BunUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
|
||||
if len(columns) > 0 {
|
||||
b.query = b.query.ExcludeColumn(columns...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *BunUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
|
||||
b.query = b.query.Where(query, args...)
|
||||
return b
|
||||
|
||||
@@ -751,6 +751,13 @@ func (g *GormInsertQuery) OnConflict(action string) common.InsertQuery {
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *GormInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
|
||||
if len(columns) > 0 {
|
||||
g.db = g.db.Omit(columns...)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *GormInsertQuery) Returning(columns ...string) common.InsertQuery {
|
||||
g.returningColumns = columns
|
||||
return g
|
||||
@@ -930,6 +937,13 @@ func (g *GormUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQue
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *GormUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
|
||||
if len(columns) > 0 {
|
||||
g.db = g.db.Omit(columns...)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *GormUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
|
||||
g.db = g.db.Where(query, args...)
|
||||
return g
|
||||
|
||||
@@ -691,6 +691,13 @@ func (p *PgSQLInsertQuery) OnConflict(action string) common.InsertQuery {
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PgSQLInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
|
||||
for _, col := range columns {
|
||||
delete(p.values, col)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PgSQLInsertQuery) Returning(columns ...string) common.InsertQuery {
|
||||
p.returning = columns
|
||||
return p
|
||||
@@ -850,6 +857,13 @@ func (p *PgSQLUpdateQuery) Set(column string, value interface{}) common.UpdateQu
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PgSQLUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
|
||||
for _, col := range columns {
|
||||
delete(p.sets, col)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PgSQLUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
|
||||
pkName := ""
|
||||
if p.model != nil {
|
||||
|
||||
@@ -81,6 +81,8 @@ type InsertQuery interface {
|
||||
Table(table string) InsertQuery
|
||||
Value(column string, value interface{}) InsertQuery
|
||||
OnConflict(action string) InsertQuery
|
||||
// ExcludeColumn omits columns from a Model()-based INSERT (e.g. generated columns).
|
||||
ExcludeColumn(columns ...string) InsertQuery
|
||||
Returning(columns ...string) InsertQuery
|
||||
|
||||
// Execution
|
||||
@@ -94,6 +96,8 @@ type UpdateQuery interface {
|
||||
Table(table string) UpdateQuery
|
||||
Set(column string, value interface{}) UpdateQuery
|
||||
SetMap(values map[string]interface{}) UpdateQuery
|
||||
// ExcludeColumn omits columns from a Model()-based UPDATE (e.g. generated columns).
|
||||
ExcludeColumn(columns ...string) UpdateQuery
|
||||
Where(query string, args ...interface{}) UpdateQuery
|
||||
Returning(columns ...string) UpdateQuery
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
|
||||
case "insert", "create", "add":
|
||||
// Only perform insert if we have data to insert
|
||||
if hasData {
|
||||
id, err := p.processInsert(ctx, regularData, tableName)
|
||||
id, err := p.processInsert(ctx, regularData, model, tableName)
|
||||
if err != nil {
|
||||
logger.Error("Insert failed for table=%s, data=%+v, error=%v", tableName, regularData, err)
|
||||
return nil, fmt.Errorf("insert failed: %w", err)
|
||||
@@ -148,7 +148,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
|
||||
return result, nil
|
||||
}
|
||||
if hasData {
|
||||
rows, err := p.processUpdate(ctx, regularData, tableName, data[pkName])
|
||||
rows, err := p.processUpdate(ctx, regularData, model, tableName, data[pkName])
|
||||
if err != nil {
|
||||
logger.Error("Update failed for table=%s, id=%v, data=%+v, error=%v", tableName, data[pkName], regularData, err)
|
||||
return nil, fmt.Errorf("update failed: %w", err)
|
||||
@@ -295,10 +295,12 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode
|
||||
func (p *NestedCUDProcessor) processInsert(
|
||||
ctx context.Context,
|
||||
data map[string]interface{},
|
||||
model interface{},
|
||||
tableName string,
|
||||
) (interface{}, error) {
|
||||
logger.Debug("Inserting into %s with data: %+v", tableName, data)
|
||||
|
||||
reflection.RemoveNonWritableColumns(model, data)
|
||||
query := p.db.NewInsert().Table(tableName)
|
||||
|
||||
for key, value := range data {
|
||||
@@ -335,6 +337,7 @@ func (p *NestedCUDProcessor) processSelect(ctx context.Context, tableName string
|
||||
func (p *NestedCUDProcessor) processUpdate(
|
||||
ctx context.Context,
|
||||
data map[string]interface{},
|
||||
model interface{},
|
||||
tableName string,
|
||||
id interface{},
|
||||
) (int64, error) {
|
||||
@@ -345,6 +348,7 @@ func (p *NestedCUDProcessor) processUpdate(
|
||||
|
||||
logger.Debug("Updating %s with ID %v, data: %+v", tableName, id, data)
|
||||
|
||||
reflection.RemoveNonWritableColumns(model, data)
|
||||
query := p.db.NewUpdate().Table(tableName).SetMap(data).Where(fmt.Sprintf("%s = ?", QuoteIdent(reflection.GetPrimaryKeyName(tableName))), id)
|
||||
|
||||
result, err := query.Exec(ctx)
|
||||
|
||||
@@ -99,6 +99,7 @@ func (m *mockInsertQuery) Value(column string, value interface{}) InsertQuery {
|
||||
return m
|
||||
}
|
||||
func (m *mockInsertQuery) OnConflict(action string) InsertQuery { return m }
|
||||
func (m *mockInsertQuery) ExcludeColumn(columns ...string) InsertQuery { return m }
|
||||
func (m *mockInsertQuery) Returning(columns ...string) InsertQuery { return m }
|
||||
func (m *mockInsertQuery) Exec(ctx context.Context) (Result, error) {
|
||||
m.db.insertCalls = append(m.db.insertCalls, m.values)
|
||||
@@ -131,6 +132,7 @@ func (m *mockUpdateQuery) SetMap(values map[string]interface{}) UpdateQuery {
|
||||
return m
|
||||
}
|
||||
func (m *mockUpdateQuery) Where(condition string, args ...interface{}) UpdateQuery { return m }
|
||||
func (m *mockUpdateQuery) ExcludeColumn(columns ...string) UpdateQuery { return m }
|
||||
func (m *mockUpdateQuery) Returning(columns ...string) UpdateQuery { return m }
|
||||
func (m *mockUpdateQuery) Exec(ctx context.Context) (Result, error) {
|
||||
// Record the update call
|
||||
|
||||
@@ -895,6 +895,9 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
|
||||
|
||||
// Insert record
|
||||
query := hookCtx.Tx.NewInsert().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
||||
if generated := reflection.NonWritableColumns(hookCtx.Model); len(generated) > 0 {
|
||||
query = query.ExcludeColumn(generated...)
|
||||
}
|
||||
if _, err := query.Exec(hookCtx.Context); err != nil {
|
||||
return nil, fmt.Errorf("failed to create record: %w", err)
|
||||
}
|
||||
@@ -924,6 +927,8 @@ func (h *Handler) update(hookCtx *HookContext) error {
|
||||
// the stored value unless disallowNulls is set, in which case null is skipped.
|
||||
values := common.MergeUpdateValues(make(map[string]interface{}, len(updates)), updates, h.disallowNulls)
|
||||
|
||||
reflection.RemoveNonWritableColumns(hookCtx.Model, values)
|
||||
|
||||
if len(values) > 0 {
|
||||
query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values).
|
||||
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID)
|
||||
|
||||
@@ -656,7 +656,7 @@ func isColumnWritableInType(typ reflect.Type, columnName string) (found bool, wr
|
||||
// Check bun tag for scanonly
|
||||
bunTag := field.Tag.Get("bun")
|
||||
if bunTag != "" {
|
||||
if isBunFieldScanOnly(bunTag) {
|
||||
if isBunFieldScanOnly(bunTag) || isBunFieldGenerated(bunTag) {
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
@@ -689,6 +689,70 @@ func isBunFieldScanOnly(tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isBunFieldGenerated checks if a bun tag marks the column as database-generated
|
||||
// (GENERATED ALWAYS AS ... STORED), which can be read but never written.
|
||||
// Example: "email_normalized,generated" -> true
|
||||
func isBunFieldGenerated(tag string) bool {
|
||||
for _, part := range strings.Split(tag, ",") {
|
||||
if strings.TrimSpace(part) == "generated" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveNonWritableColumns deletes from values every key that maps to a
|
||||
// non-writable model column (bun scanonly/generated, gorm read-only). Used
|
||||
// before writing a read-merged record back with UPDATE ... SET.
|
||||
func RemoveNonWritableColumns(model any, values map[string]interface{}) {
|
||||
for key := range values {
|
||||
if !IsColumnWritable(model, key) {
|
||||
delete(values, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NonWritableColumns returns the column names of the model that cannot be
|
||||
// written (bun scanonly/generated, gorm read-only), including embedded structs.
|
||||
func NonWritableColumns(model any) []string {
|
||||
t := reflect.TypeOf(model)
|
||||
for t != nil && (t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array) {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
var cols []string
|
||||
collectNonWritable(t, &cols)
|
||||
return cols
|
||||
}
|
||||
|
||||
func collectNonWritable(typ reflect.Type, cols *[]string) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.Anonymous {
|
||||
ft := field.Type
|
||||
if ft.Kind() == reflect.Pointer {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
collectNonWritable(ft, cols)
|
||||
continue
|
||||
}
|
||||
}
|
||||
bunTag, gormTag := field.Tag.Get("bun"), field.Tag.Get("gorm")
|
||||
if bunTag == "-" || gormTag == "-" {
|
||||
continue
|
||||
}
|
||||
if (bunTag != "" && (isBunFieldScanOnly(bunTag) || isBunFieldGenerated(bunTag))) ||
|
||||
(gormTag != "" && isGormFieldReadOnly(gormTag)) {
|
||||
if name := getColumnNameFromField(field); name != "" {
|
||||
*cols = append(*cols, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isGormFieldReadOnly checks if a gorm tag indicates the field is read-only
|
||||
// Examples:
|
||||
// - "<-:false" -> true (no writes allowed)
|
||||
|
||||
@@ -1920,3 +1920,46 @@ func TestMapToStruct_Errors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveNonWritableColumns_Generated(t *testing.T) {
|
||||
type m struct {
|
||||
ID int `bun:"id,pk"`
|
||||
Email string `bun:"email"`
|
||||
Norm string `bun:"email_normalized,generated"`
|
||||
Scan string `bun:"scan_col,scanonly"`
|
||||
}
|
||||
vals := map[string]interface{}{"id": 1, "email": "A", "email_normalized": "a", "scan_col": "x", "dynamic": 1}
|
||||
RemoveNonWritableColumns(&m{}, vals)
|
||||
if _, ok := vals["email_normalized"]; ok {
|
||||
t.Error("generated column not removed")
|
||||
}
|
||||
if _, ok := vals["scan_col"]; ok {
|
||||
t.Error("scanonly column not removed")
|
||||
}
|
||||
if len(vals) != 3 {
|
||||
t.Errorf("unexpected keys: %v", vals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonWritableColumns(t *testing.T) {
|
||||
type base struct {
|
||||
Created string `bun:"created_at,scanonly"`
|
||||
}
|
||||
type m struct {
|
||||
base
|
||||
ID int `bun:"id,pk"`
|
||||
Email string `bun:"email"`
|
||||
Norm string `bun:"email_normalized,generated"`
|
||||
Ro string `gorm:"column:ro;->"`
|
||||
}
|
||||
got := NonWritableColumns(&m{})
|
||||
want := map[string]bool{"created_at": true, "email_normalized": true, "ro": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
for _, c := range got {
|
||||
if !want[c] {
|
||||
t.Errorf("unexpected %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +559,7 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data
|
||||
if len(cols) == 0 {
|
||||
return invalidArg("no writable fields in data")
|
||||
}
|
||||
reflection.RemoveNonWritableColumns(model, cols)
|
||||
q := tx.NewInsert().Table(tableName)
|
||||
for key, value := range cols {
|
||||
q = q.Value(key, value)
|
||||
@@ -726,6 +727,7 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string,
|
||||
existingMap[key] = v
|
||||
}
|
||||
|
||||
reflection.RemoveNonWritableColumns(model, setCols)
|
||||
q := tx.NewUpdate().Table(tableName).SetMap(setCols).
|
||||
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
|
||||
res, err := q.Exec(ctx)
|
||||
|
||||
@@ -194,6 +194,7 @@ func (h *Handler) executeWhere(ctx context.Context, req whereRequest) (_ *whereR
|
||||
cond := fmt.Sprintf("%s IN (%s)", common.QuoteIdent(pkName), strings.Join(inList, ", "))
|
||||
var affected int64
|
||||
if req.op == "update" {
|
||||
reflection.RemoveNonWritableColumns(model, setCols)
|
||||
r, err := tx.NewUpdate().Table(tableName).SetMap(setCols).Where(cond, ids...).Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating records: %w", err)
|
||||
|
||||
@@ -824,6 +824,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
}
|
||||
responseData = v
|
||||
|
||||
reflection.RemoveNonWritableColumns(model, v)
|
||||
query := tx.NewInsert().Table(tableName)
|
||||
for key, value := range v {
|
||||
query = query.Value(key, common.ConvertSliceForBun(value))
|
||||
@@ -971,6 +972,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
item = modifiedData
|
||||
}
|
||||
|
||||
reflection.RemoveNonWritableColumns(model, item)
|
||||
txQuery := tx.NewInsert().Table(tableName)
|
||||
for key, value := range item {
|
||||
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
|
||||
@@ -1127,6 +1129,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
itemMap = modifiedData
|
||||
}
|
||||
|
||||
reflection.RemoveNonWritableColumns(model, itemMap)
|
||||
txQuery := tx.NewInsert().Table(tableName)
|
||||
for key, value := range itemMap {
|
||||
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
|
||||
@@ -1322,6 +1325,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
|
||||
// Overwrite with every key present in the request (including "" and null unless disallowed)
|
||||
common.MergeUpdateValues(existingMap, updates, h.disallowNulls)
|
||||
reflection.RemoveNonWritableColumns(model, existingMap)
|
||||
|
||||
// Build update query with merged data
|
||||
query := tx.NewUpdate().Table(tableName).SetMap(existingMap)
|
||||
@@ -1507,6 +1511,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
|
||||
// Overwrite with every key present in the request (including "" and null unless disallowed)
|
||||
common.MergeUpdateValues(existingMap, item, h.disallowNulls)
|
||||
reflection.RemoveNonWritableColumns(model, existingMap)
|
||||
|
||||
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
if _, err := txQuery.Exec(ctx); err != nil {
|
||||
@@ -1662,6 +1667,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url
|
||||
|
||||
// Overwrite with every key present in the request (including "" and null unless disallowed)
|
||||
common.MergeUpdateValues(existingMap, itemMap, h.disallowNulls)
|
||||
reflection.RemoveNonWritableColumns(model, existingMap)
|
||||
|
||||
txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
|
||||
if _, err := txQuery.Exec(ctx); err != nil {
|
||||
|
||||
@@ -1410,6 +1410,9 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
|
||||
if provider, ok := modelValue.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
||||
query = query.Table(tableName)
|
||||
}
|
||||
if generated := reflection.NonWritableColumns(model); len(generated) > 0 {
|
||||
query = query.ExcludeColumn(generated...)
|
||||
}
|
||||
fields := reflection.GetSQLModelColumns(model)
|
||||
query = query.Returning(fields...)
|
||||
|
||||
@@ -1657,6 +1660,9 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
|
||||
|
||||
// Create update query using Model() to preserve custom types and driver.Valuer interfaces
|
||||
query := tx.NewUpdate().Model(modelInstance)
|
||||
if generated := reflection.NonWritableColumns(model); len(generated) > 0 {
|
||||
query = query.ExcludeColumn(generated...)
|
||||
}
|
||||
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||
|
||||
// Execute BeforeScan hooks - pass query chain so hooks can modify it
|
||||
|
||||
@@ -758,6 +758,9 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
|
||||
|
||||
// Insert record
|
||||
query := hookCtx.Tx.NewInsert().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
|
||||
if generated := reflection.NonWritableColumns(hookCtx.Model); len(generated) > 0 {
|
||||
query = query.ExcludeColumn(generated...)
|
||||
}
|
||||
if _, err := query.Exec(hookCtx.Context); err != nil {
|
||||
return nil, fmt.Errorf("failed to create record: %w", err)
|
||||
}
|
||||
@@ -786,6 +789,8 @@ func (h *Handler) update(hookCtx *HookContext) error {
|
||||
// the stored value unless disallowNulls is set, in which case null is skipped.
|
||||
values := common.MergeUpdateValues(make(map[string]interface{}, len(updates)), updates, h.disallowNulls)
|
||||
|
||||
reflection.RemoveNonWritableColumns(hookCtx.Model, values)
|
||||
|
||||
if len(values) > 0 {
|
||||
query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values).
|
||||
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID)
|
||||
|
||||
@@ -226,6 +226,11 @@ func (m *MockInsertQuery) OnConflict(action string) common.InsertQuery {
|
||||
return args.Get(0).(common.InsertQuery)
|
||||
}
|
||||
|
||||
func (m *MockInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
|
||||
args := m.Called(columns)
|
||||
return args.Get(0).(common.InsertQuery)
|
||||
}
|
||||
|
||||
func (m *MockInsertQuery) Returning(columns ...string) common.InsertQuery {
|
||||
args := m.Called(columns)
|
||||
return args.Get(0).(common.InsertQuery)
|
||||
@@ -254,6 +259,11 @@ func (m *MockUpdateQuery) Model(model interface{}) common.UpdateQuery {
|
||||
return args.Get(0).(common.UpdateQuery)
|
||||
}
|
||||
|
||||
func (m *MockUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
|
||||
args := m.Called(columns)
|
||||
return args.Get(0).(common.UpdateQuery)
|
||||
}
|
||||
|
||||
func (m *MockUpdateQuery) Table(table string) common.UpdateQuery {
|
||||
args := m.Called(table)
|
||||
return args.Get(0).(common.UpdateQuery)
|
||||
|
||||
Reference in New Issue
Block a user