Compare commits

..
4 Commits
Author SHA1 Message Date
warkanum 9235292586 fix(crud): skip generated and read-only columns on insert and update
Tests / Integration Tests (push) Skipped
Build , Vet Test, and Lint / Build (push) Successful in 1m34s
Tests / Unit Tests (push) Successful in 1m42s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m11s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m25s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m28s
Tests / Race Detector (push) Successful in 4m6s
Read-merge-update wrote every model column back, so GENERATED ALWAYS
columns failed with SQLSTATE 428C9. Add a bun 'generated' tag option,
reflection.NonWritableColumns/RemoveNonWritableColumns, and apply them
in resolvespec, restheadspec, websocketspec, mqttspec, resolvemcp and
the nested CUD processor. Add ExcludeColumn to InsertQuery/UpdateQuery
for model-based writes.
2026-10-02 22:43:45 +02:00
warkanum 23f10387c5 ci(release): fix rust toolchain setup and dart publish validation warnings
Tests / Integration Tests (push) Skipped
Build , Vet Test, and Lint / Build (push) Successful in 3m15s
Tests / Unit Tests (push) Successful in 3m26s
Build , Vet Test, and Lint / Lint Code (push) Successful in 4m45s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 4m55s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 4m55s
Tests / Race Detector (push) Successful in 7m6s
2026-10-01 21:20:04 +02:00
warkanum 0d3ad9e4fd ci(tests): disable integration tests job and install psql client 2026-10-01 21:16:34 +02:00
warkanum f5d232d971 ci: move workflows to Gitea and add client release workflow
Tests / Integration Tests (push) Failing after 1m39s
Build , Vet Test, and Lint / Build (push) Successful in 2m3s
Tests / Unit Tests (push) Successful in 2m8s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m51s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m57s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m59s
Tests / Race Detector (push) Successful in 5m9s
Move .github/workflows to .gitea/workflows with Gitea-compatible action
versions, fix make_tag outputs/major bump, and add release_clients.yml to
build and publish all clients to the Gitea package registries. Rename the Go
client module to git.warky.dev/wdevs and add LICENSE/CHANGELOG for Dart.
2026-10-01 21:11:41 +02:00
24 changed files with 523 additions and 33 deletions
@@ -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) name: Create Go Release (Tag Versioning)
on: on:
@@ -26,7 +23,9 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Git - name: Set up Git
run: | run: |
@@ -38,7 +37,7 @@ jobs:
run: | run: |
git fetch --tags git fetch --tags
latest_tag=$(git describe --tags `git rev-list --tags --max-count=1`) 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 - name: Determine new tag version
id: new_tag id: new_tag
@@ -57,7 +56,7 @@ jobs:
((minor++)) ((minor++))
patch=0 patch=0
;; ;;
"release") "major")
((major++)) ((major++))
minor=0 minor=0
patch=0 patch=0
@@ -68,15 +67,11 @@ jobs:
;; ;;
esac esac
new_tag="v$major.$minor.$patch" new_tag="v$major.$minor.$patch"
echo "::set-output name=tag::$new_tag" echo "tag=${new_tag}" >> "${GITHUB_OUTPUT}"
- name: Create tag - name: Create tag
run: | run: |
git tag -a ${{ steps.new_tag.outputs.tag }} -m "Tagging ${{ steps.new_tag.outputs.tag }} for release" git tag -a ${{ steps.new_tag.outputs.tag }} -m "Tagging ${{ steps.new_tag.outputs.tag }} for release"
- name: Push changes - name: Push tag
uses: ad-m/github-push-action@master run: git push origin ${{ steps.new_tag.outputs.tag }}
with:
github_token: ${{ secrets.BITECH_GITHUB_TOKEN }}
force: true
tags: true
+268
View File
@@ -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 name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: "1.24" go-version: "1.24"
- name: Run unit tests - name: Run unit tests
@@ -22,7 +22,7 @@ jobs:
go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html go tool cover -html=coverage.out -o coverage.html
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v3
continue-on-error: true continue-on-error: true
with: with:
name: coverage-report name: coverage-report
@@ -31,15 +31,16 @@ jobs:
name: Race Detector name: Race Detector
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: "1.24" go-version: "1.24"
- name: Run unit tests with the race detector - name: Run unit tests with the race detector
run: go test -race -count=1 ./pkg/... run: go test -race -count=1 ./pkg/...
integration-tests: integration-tests:
name: Integration Tests name: Integration Tests
if: false # disabled for now
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: postgres:
@@ -56,46 +57,51 @@ jobs:
ports: ports:
- 5432:5432 - 5432:5432
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: "1.24" 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 - name: Create test databases
env: env:
PGPASSWORD: postgres PGPASSWORD: postgres
run: | run: |
psql -h localhost -U postgres -c "CREATE DATABASE resolvespec_test;" psql -h postgres -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 restheadspec_test;"
- name: Run resolvespec integration tests - name: Run resolvespec integration tests
continue-on-error: true continue-on-error: true
env: 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 run: go test -tags=integration ./pkg/resolvespec -v -coverprofile=coverage-resolvespec-integration.out
- name: Run restheadspec integration tests - name: Run restheadspec integration tests
continue-on-error: true continue-on-error: true
env: 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 run: go test -tags=integration ./pkg/restheadspec -v -coverprofile=coverage-restheadspec-integration.out
- name: Generate integration coverage - name: Generate integration coverage
continue-on-error: true continue-on-error: true
env: 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: | run: |
go tool cover -html=coverage-resolvespec-integration.out -o coverage-resolvespec-integration.html 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 go tool cover -html=coverage-restheadspec-integration.out -o coverage-restheadspec-integration.html
- name: Upload resolvespec integration coverage - name: Upload resolvespec integration coverage
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v3
continue-on-error: true continue-on-error: true
with: with:
name: resolvespec-integration-coverage-report name: resolvespec-integration-coverage-report
path: coverage-resolvespec-integration.html path: coverage-resolvespec-integration.html
- name: Upload restheadspec integration coverage - name: Upload restheadspec integration coverage
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v3
continue-on-error: true continue-on-error: true
with: with:
name: integration-coverage-restheadspec-report name: integration-coverage-restheadspec-report
path: coverage-restheadspec-integration path: coverage-restheadspec-integration.html
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- Initial release: ResolveSpec (JSON body) and FunctionSpec client.
+21
View File
@@ -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
View File
@@ -1,6 +1,7 @@
name: resolvespec name: resolvespec
description: Client for ResolveSpec (JSON body) and FunctionSpec endpoints. description: Client for ResolveSpec (JSON body) and FunctionSpec endpoints.
version: 0.1.0 version: 0.1.0
repository: https://git.warky.dev/wdevs/ResolveSpec
publish_to: none publish_to: none
environment: environment:
+1 -1
View File
@@ -1,6 +1,6 @@
# resolvespec-go # 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 ## Clients
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/bitechdev/ResolveSpec/clients/resolvespec-go module git.warky.dev/wdevs/ResolveSpec/clients/resolvespec-go
go 1.22 go 1.22
+14
View File
@@ -1507,6 +1507,13 @@ func (b *BunInsertQuery) OnConflict(action string) common.InsertQuery {
return b 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 { func (b *BunInsertQuery) Returning(columns ...string) common.InsertQuery {
if len(columns) > 0 { if len(columns) > 0 {
b.query = b.query.Returning(strings.Join(columns, ", ")) b.query = b.query.Returning(strings.Join(columns, ", "))
@@ -1619,6 +1626,13 @@ func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuer
return b 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 { func (b *BunUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
b.query = b.query.Where(query, args...) b.query = b.query.Where(query, args...)
return b return b
+14
View File
@@ -751,6 +751,13 @@ func (g *GormInsertQuery) OnConflict(action string) common.InsertQuery {
return g 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 { func (g *GormInsertQuery) Returning(columns ...string) common.InsertQuery {
g.returningColumns = columns g.returningColumns = columns
return g return g
@@ -930,6 +937,13 @@ func (g *GormUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQue
return g 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 { func (g *GormUpdateQuery) Where(query string, args ...interface{}) common.UpdateQuery {
g.db = g.db.Where(query, args...) g.db = g.db.Where(query, args...)
return g return g
+14
View File
@@ -691,6 +691,13 @@ func (p *PgSQLInsertQuery) OnConflict(action string) common.InsertQuery {
return p 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 { func (p *PgSQLInsertQuery) Returning(columns ...string) common.InsertQuery {
p.returning = columns p.returning = columns
return p return p
@@ -850,6 +857,13 @@ func (p *PgSQLUpdateQuery) Set(column string, value interface{}) common.UpdateQu
return p 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 { func (p *PgSQLUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuery {
pkName := "" pkName := ""
if p.model != nil { if p.model != nil {
+4
View File
@@ -81,6 +81,8 @@ type InsertQuery interface {
Table(table string) InsertQuery Table(table string) InsertQuery
Value(column string, value interface{}) InsertQuery Value(column string, value interface{}) InsertQuery
OnConflict(action string) 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 Returning(columns ...string) InsertQuery
// Execution // Execution
@@ -94,6 +96,8 @@ type UpdateQuery interface {
Table(table string) UpdateQuery Table(table string) UpdateQuery
Set(column string, value interface{}) UpdateQuery Set(column string, value interface{}) UpdateQuery
SetMap(values map[string]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 Where(query string, args ...interface{}) UpdateQuery
Returning(columns ...string) UpdateQuery Returning(columns ...string) UpdateQuery
+6 -2
View File
@@ -116,7 +116,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
case "insert", "create", "add": case "insert", "create", "add":
// Only perform insert if we have data to insert // Only perform insert if we have data to insert
if hasData { if hasData {
id, err := p.processInsert(ctx, regularData, tableName) id, err := p.processInsert(ctx, regularData, model, tableName)
if err != nil { if err != nil {
logger.Error("Insert failed for table=%s, data=%+v, error=%v", tableName, regularData, err) logger.Error("Insert failed for table=%s, data=%+v, error=%v", tableName, regularData, err)
return nil, fmt.Errorf("insert failed: %w", err) return nil, fmt.Errorf("insert failed: %w", err)
@@ -148,7 +148,7 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
return result, nil return result, nil
} }
if hasData { if hasData {
rows, err := p.processUpdate(ctx, regularData, tableName, data[pkName]) rows, err := p.processUpdate(ctx, regularData, model, tableName, data[pkName])
if err != nil { if err != nil {
logger.Error("Update failed for table=%s, id=%v, data=%+v, error=%v", tableName, data[pkName], regularData, err) 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) 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( func (p *NestedCUDProcessor) processInsert(
ctx context.Context, ctx context.Context,
data map[string]interface{}, data map[string]interface{},
model interface{},
tableName string, tableName string,
) (interface{}, error) { ) (interface{}, error) {
logger.Debug("Inserting into %s with data: %+v", tableName, data) logger.Debug("Inserting into %s with data: %+v", tableName, data)
reflection.RemoveNonWritableColumns(model, data)
query := p.db.NewInsert().Table(tableName) query := p.db.NewInsert().Table(tableName)
for key, value := range data { for key, value := range data {
@@ -335,6 +337,7 @@ func (p *NestedCUDProcessor) processSelect(ctx context.Context, tableName string
func (p *NestedCUDProcessor) processUpdate( func (p *NestedCUDProcessor) processUpdate(
ctx context.Context, ctx context.Context,
data map[string]interface{}, data map[string]interface{},
model interface{},
tableName string, tableName string,
id interface{}, id interface{},
) (int64, error) { ) (int64, error) {
@@ -345,6 +348,7 @@ func (p *NestedCUDProcessor) processUpdate(
logger.Debug("Updating %s with ID %v, data: %+v", tableName, id, data) 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) query := p.db.NewUpdate().Table(tableName).SetMap(data).Where(fmt.Sprintf("%s = ?", QuoteIdent(reflection.GetPrimaryKeyName(tableName))), id)
result, err := query.Exec(ctx) result, err := query.Exec(ctx)
+2
View File
@@ -99,6 +99,7 @@ func (m *mockInsertQuery) Value(column string, value interface{}) InsertQuery {
return m return m
} }
func (m *mockInsertQuery) OnConflict(action string) 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) Returning(columns ...string) InsertQuery { return m }
func (m *mockInsertQuery) Exec(ctx context.Context) (Result, error) { func (m *mockInsertQuery) Exec(ctx context.Context) (Result, error) {
m.db.insertCalls = append(m.db.insertCalls, m.values) m.db.insertCalls = append(m.db.insertCalls, m.values)
@@ -131,6 +132,7 @@ func (m *mockUpdateQuery) SetMap(values map[string]interface{}) UpdateQuery {
return m return m
} }
func (m *mockUpdateQuery) Where(condition string, args ...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) Returning(columns ...string) UpdateQuery { return m }
func (m *mockUpdateQuery) Exec(ctx context.Context) (Result, error) { func (m *mockUpdateQuery) Exec(ctx context.Context) (Result, error) {
// Record the update call // Record the update call
+5
View File
@@ -895,6 +895,9 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
// Insert record // Insert record
query := hookCtx.Tx.NewInsert().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) 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 { if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to create record: %w", err) 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. // 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) values := common.MergeUpdateValues(make(map[string]interface{}, len(updates)), updates, h.disallowNulls)
reflection.RemoveNonWritableColumns(hookCtx.Model, values)
if len(values) > 0 { if len(values) > 0 {
query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values). query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID) Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID)
+65 -1
View File
@@ -656,7 +656,7 @@ func isColumnWritableInType(typ reflect.Type, columnName string) (found bool, wr
// Check bun tag for scanonly // Check bun tag for scanonly
bunTag := field.Tag.Get("bun") bunTag := field.Tag.Get("bun")
if bunTag != "" { if bunTag != "" {
if isBunFieldScanOnly(bunTag) { if isBunFieldScanOnly(bunTag) || isBunFieldGenerated(bunTag) {
return true, false return true, false
} }
} }
@@ -689,6 +689,70 @@ func isBunFieldScanOnly(tag string) bool {
return false 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 // isGormFieldReadOnly checks if a gorm tag indicates the field is read-only
// Examples: // Examples:
// - "<-:false" -> true (no writes allowed) // - "<-:false" -> true (no writes allowed)
+43
View File
@@ -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)
}
}
}
+2
View File
@@ -559,6 +559,7 @@ func (h *Handler) executeCreate(ctx context.Context, schema, entity string, data
if len(cols) == 0 { if len(cols) == 0 {
return invalidArg("no writable fields in data") return invalidArg("no writable fields in data")
} }
reflection.RemoveNonWritableColumns(model, cols)
q := tx.NewInsert().Table(tableName) q := tx.NewInsert().Table(tableName)
for key, value := range cols { for key, value := range cols {
q = q.Value(key, value) q = q.Value(key, value)
@@ -726,6 +727,7 @@ func (h *Handler) executeUpdate(ctx context.Context, schema, entity, id string,
existingMap[key] = v existingMap[key] = v
} }
reflection.RemoveNonWritableColumns(model, setCols)
q := tx.NewUpdate().Table(tableName).SetMap(setCols). q := tx.NewUpdate().Table(tableName).SetMap(setCols).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id) Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), id)
res, err := q.Exec(ctx) res, err := q.Exec(ctx)
+1
View File
@@ -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, ", ")) cond := fmt.Sprintf("%s IN (%s)", common.QuoteIdent(pkName), strings.Join(inList, ", "))
var affected int64 var affected int64
if req.op == "update" { if req.op == "update" {
reflection.RemoveNonWritableColumns(model, setCols)
r, err := tx.NewUpdate().Table(tableName).SetMap(setCols).Where(cond, ids...).Exec(ctx) r, err := tx.NewUpdate().Table(tableName).SetMap(setCols).Where(cond, ids...).Exec(ctx)
if err != nil { if err != nil {
return fmt.Errorf("error updating records: %w", err) return fmt.Errorf("error updating records: %w", err)
+6
View File
@@ -824,6 +824,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
} }
responseData = v responseData = v
reflection.RemoveNonWritableColumns(model, v)
query := tx.NewInsert().Table(tableName) query := tx.NewInsert().Table(tableName)
for key, value := range v { for key, value := range v {
query = query.Value(key, common.ConvertSliceForBun(value)) query = query.Value(key, common.ConvertSliceForBun(value))
@@ -971,6 +972,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
item = modifiedData item = modifiedData
} }
reflection.RemoveNonWritableColumns(model, item)
txQuery := tx.NewInsert().Table(tableName) txQuery := tx.NewInsert().Table(tableName)
for key, value := range item { for key, value := range item {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) txQuery = txQuery.Value(key, common.ConvertSliceForBun(value))
@@ -1127,6 +1129,7 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
itemMap = modifiedData itemMap = modifiedData
} }
reflection.RemoveNonWritableColumns(model, itemMap)
txQuery := tx.NewInsert().Table(tableName) txQuery := tx.NewInsert().Table(tableName)
for key, value := range itemMap { for key, value := range itemMap {
txQuery = txQuery.Value(key, common.ConvertSliceForBun(value)) 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) // Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, updates, h.disallowNulls) common.MergeUpdateValues(existingMap, updates, h.disallowNulls)
reflection.RemoveNonWritableColumns(model, existingMap)
// Build update query with merged data // Build update query with merged data
query := tx.NewUpdate().Table(tableName).SetMap(existingMap) 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) // Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, item, h.disallowNulls) 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) txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil { 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) // Overwrite with every key present in the request (including "" and null unless disallowed)
common.MergeUpdateValues(existingMap, itemMap, h.disallowNulls) 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) txQuery := tx.NewUpdate().Table(tableName).SetMap(existingMap).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), itemID)
if _, err := txQuery.Exec(ctx); err != nil { if _, err := txQuery.Exec(ctx); err != nil {
+6
View File
@@ -1410,6 +1410,9 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
if provider, ok := modelValue.(common.TableNameProvider); !ok || provider.TableName() == "" { if provider, ok := modelValue.(common.TableNameProvider); !ok || provider.TableName() == "" {
query = query.Table(tableName) query = query.Table(tableName)
} }
if generated := reflection.NonWritableColumns(model); len(generated) > 0 {
query = query.ExcludeColumn(generated...)
}
fields := reflection.GetSQLModelColumns(model) fields := reflection.GetSQLModelColumns(model)
query = query.Returning(fields...) 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 // Create update query using Model() to preserve custom types and driver.Valuer interfaces
query := tx.NewUpdate().Model(modelInstance) 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) query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
// Execute BeforeScan hooks - pass query chain so hooks can modify it // Execute BeforeScan hooks - pass query chain so hooks can modify it
+5
View File
@@ -758,6 +758,9 @@ func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
// Insert record // Insert record
query := hookCtx.Tx.NewInsert().Model(hookCtx.ModelPtr).Table(hookCtx.TableName) 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 { if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to create record: %w", err) 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. // 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) values := common.MergeUpdateValues(make(map[string]interface{}, len(updates)), updates, h.disallowNulls)
reflection.RemoveNonWritableColumns(hookCtx.Model, values)
if len(values) > 0 { if len(values) > 0 {
query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values). query := hookCtx.Tx.NewUpdate().Table(hookCtx.TableName).SetMap(values).
Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID) Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), hookCtx.ID)
+10
View File
@@ -226,6 +226,11 @@ func (m *MockInsertQuery) OnConflict(action string) common.InsertQuery {
return args.Get(0).(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 { func (m *MockInsertQuery) Returning(columns ...string) common.InsertQuery {
args := m.Called(columns) args := m.Called(columns)
return args.Get(0).(common.InsertQuery) return args.Get(0).(common.InsertQuery)
@@ -254,6 +259,11 @@ func (m *MockUpdateQuery) Model(model interface{}) common.UpdateQuery {
return args.Get(0).(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 { func (m *MockUpdateQuery) Table(table string) common.UpdateQuery {
args := m.Called(table) args := m.Called(table)
return args.Get(0).(common.UpdateQuery) return args.Get(0).(common.UpdateQuery)