Compare commits

...
92 Commits
Author SHA1 Message Date
Gani Georgiev bd76130cd8 [#] added missing import in the api preview for deleting records 2022-08-12 12:20:56 +03:00
Gani Georgiev d60dd13581 [#294] added additional inline serving mime types 2022-08-11 20:09:26 +03:00
Gani Georgiev 19d4fc04c1 fixed UI import preview when replacing ids with missing old fields 2022-08-11 15:59:14 +03:00
Gani Georgiev a7b29c1961 updated dependencies and rebuilt ui/dist 2022-08-11 11:33:01 +03:00
Gani Georgiev 147344546b added custom insertion id regex check 2022-08-11 10:29:01 +03:00
Gani Georgiev ff935a39a1 removed spacing 2022-08-11 08:13:07 +03:00
Gani Georgiev 2cce0b17b0 synced with master 2022-08-10 20:53:13 +03:00
Gani Georgiev 5f25572e95 updated ui/dist 2022-08-10 20:46:45 +03:00
Gani Georgiev dd5c9ccce8 changed btns transition to local 2022-08-10 20:45:32 +03:00
Olle MånssonandOlle Månsson da29141248 [#288] added info about the default 100x100 thumb size in the field tooltip
Co-authored-by: Olle Månsson <olle.mansson@zenseact.com>
2022-08-10 20:40:52 +03:00
Gani Georgiev 7f6049ebd6 updated test db and ui/dist 2022-08-10 20:37:41 +03:00
Gani Georgiev 119e1fb3f2 use fixed ids in the default profiles system collections migration 2022-08-10 19:08:29 +03:00
Gani Georgiev c5091898ae added test to test schema field persistence after import 2022-08-10 17:43:55 +03:00
Gani Georgiev 35bd395e55 removed unused lib 2022-08-10 17:43:08 +03:00
Gani Georgiev d56b8fcb90 updated import popup handling and api preview examples 2022-08-10 16:16:59 +03:00
Gani Georgiev 65b830198b test deleteMissing with schema changes 2022-08-10 13:22:27 +03:00
Gani Georgiev ac0c23ff64 fixed list js example 2022-08-09 17:57:32 +03:00
Gani Georgiev a355c8e8a9 removed duplicated method 2022-08-09 17:20:22 +03:00
Gani Georgiev c346dd69a4 updated ui/dist 2022-08-09 17:17:13 +03:00
Gani Georgiev f8f785d6e3 call transaction Dao events only after commit, added totalPages to the search response and updated the tests 2022-08-09 16:20:39 +03:00
Gani Georgiev 8288da8372 added version number in the footer 2022-08-09 16:16:09 +03:00
Gani Georgiev 8b2b26c196 fixed after hooks 2022-08-08 20:14:46 +03:00
Gani Georgiev 8009d37d24 updated tests 2022-08-08 19:16:33 +03:00
Gani Georgiev 6e9d000426 before updateding test data 2022-08-07 20:58:21 +03:00
Gani Georgiev a426484916 added WithConfig factory to all forms 2022-08-07 15:38:21 +03:00
Gani Georgiev b0ca9b2f1b updated export/import form 2022-08-07 11:14:49 +03:00
Gani Georgiev 956263d1fc updated admin and user upsert forms 2022-08-06 22:16:58 +03:00
Gani Georgiev 51d635bc12 fixed popup hide 2022-08-06 18:59:28 +03:00
Gani Georgiev 5fb45a1864 updated CollectionsImport and CollectionUpsert forms 2022-08-06 18:15:18 +03:00
Gani Georgiev 4e58e7ad6a added ImportPopup 2022-08-06 08:03:34 +03:00
Gani Georgiev 93ab5fbea2 added page export and import 2022-08-05 23:25:16 +03:00
Gani Georgiev f459dd8812 import scaffoldings 2022-08-05 06:00:38 +03:00
Gani Georgiev 95f9d685dc updated base model comments 2022-08-03 16:26:36 +03:00
Gani Georgiev bb00f198bc changed btns transition to local 2022-08-03 10:16:13 +03:00
Gani Georgiev 0e1b9a3897 updated go version constraint for the github action 2022-08-02 21:36:36 +03:00
Gani Georgiev e32cf12908 updated ui/dist 2022-08-02 21:32:36 +03:00
Gani Georgiev c152f99793 fixed datepicker clipped input borders 2022-08-02 21:31:42 +03:00
Gani Georgiev f8b7a40837 switch to stable go 1.19.0 2022-08-02 20:57:03 +03:00
Gani Georgiev a049a37624 updated sdk to ^0.3.0 2022-08-02 17:00:14 +03:00
Gani Georgiev 8268c26d8b fixed README typo 2022-08-01 22:30:14 +03:00
Gani Georgiev 6292e6cc2e unify the prod and dev env and use only relative base path ./ in the Admin UI 2022-08-01 20:55:31 +03:00
Gani Georgiev fbeaabf6aa removed no longer needed ui.DistIndexHTML var 2022-08-01 20:53:06 +03:00
Gani Georgiev 30d1b9f358 refactored the admin ui routes registration for better sub-path deployment support 2022-08-01 20:37:51 +03:00
Gani Georgiev 16fa099685 added default admin CSP 2022-08-01 18:10:18 +03:00
Gani Georgiev bb4bebc724 removed box-shadow for image-preview modals 2022-08-01 18:00:06 +03:00
Gani Georgiev 9d0ea7635b [#204] fixed query string parsing 2022-08-01 14:20:21 +03:00
Yin Shanyang d35134e913 [#250] added armv7 on linux as a build target 2022-08-01 10:29:06 +03:00
Gani Georgiev ce8af46fff unified file field styles 2022-07-31 23:28:47 +03:00
Gani Georgiev 87ecb1114c cleaning up no longer needed ui helper methods 2022-07-31 23:21:55 +03:00
Gani Georgiev c070be2c47 [#238] removed implicit select items grouping 2022-07-31 18:52:51 +03:00
Gani Georgiev 4f0041a128 added dart-sdk to the readme 2022-07-31 13:05:05 +03:00
Gani Georgiev 0ac24af7c9 updated ui/dist 2022-07-31 11:51:06 +03:00
Gani Georgiev 96b2c5fedf added Dart to the api preview examples 2022-07-30 21:04:44 +03:00
Gani Georgiev 4019ca5f00 [#223] change the default prod backend url to relative path to support sub-path deployment 2022-07-30 20:59:09 +03:00
Gani Georgiev 20fe3c8c91 added debug log for the invalid uploaded file(s) 2022-07-30 14:11:08 +03:00
Gani Georgiev d87a5e544c updated admin ui dependencies 2022-07-30 08:02:41 +03:00
Gani Georgiev bb527be493 fixed panic on expanding existing byt non-relation type field 2022-07-30 07:58:42 +03:00
Gani Georgiev 9e3b230c8e added debug log for established realtime connection 2022-07-28 08:26:05 +03:00
Gani Georgiev 686198a22e normalize number filter literals
Always cast number literals to provide consistent eq/neq behavior when combined with COALESCE, because '1' = 1 is TRUE but COALESCE('1', '') = COALESCE(1, '') will result to FALSE.
2022-07-28 05:23:58 +03:00
Gani Georgiev 086b992c7d [#228] added target=_blank to the email links 2022-07-26 15:17:10 +03:00
Takeshi Sato 88d8cec3d9 [#207] use read-only scopes for the GitHub OAuth2 provider 2022-07-24 18:04:53 +03:00
Gani Georgiev 7926501649 updated comments and added CSP header check in the tests 2022-07-21 17:22:31 +03:00
Gani Georgiev 4c2cd5a534 simplify the svg extension check 2022-07-21 12:58:06 +03:00
Gani Georgiev 5d8fc939e2 [#164] serve common media files inline and fix svg content-type 2022-07-21 12:56:17 +03:00
Gani Georgiev 1a5180d7d3 added support to filter request.user.profile relation fields 2022-07-20 22:33:24 +03:00
Gani Georgiev 8a08a4764d [#166] fixed api preview examples 2022-07-19 19:41:03 +03:00
Gani Georgiev 66b317f01c run tests before goreleaser 2022-07-19 17:24:58 +03:00
Gani Georgiev ab5a770346 updated tests 2022-07-19 17:23:34 +03:00
Gani Georgiev 65697add43 temporary skip tests in the release action until the async email hooks get fixed 2022-07-19 14:46:02 +03:00
Gani Georgiev 841415f0ff move field delete in a dropdown to prevent accidental clicks 2022-07-19 14:36:35 +03:00
Gani Georgiev f295ce9403 run added password reset and verification sent hooks tests 2022-07-19 14:20:28 +03:00
Gani Georgiev 383b2a1279 [#160] support expand query parameter for create and update requests 2022-07-19 13:31:52 +03:00
Gani Georgiev 73fb12c2bc [#156] added forcePathStyle to the s3 config 2022-07-19 10:45:38 +03:00
Kenneth Lee 571c4dcc8d [#163] fixed migrate down cmd 2022-07-18 23:00:54 +03:00
Gani Georgiev f56adf26f4 added the app name in the document title and fixed the double initial load on records list 2022-07-18 19:44:10 +03:00
Gani Georgiev f8f3ca25ee updated duplicated field name error 2022-07-18 18:10:30 +03:00
Gani Georgiev e01f76d37b allow switching schema field names when renaming fields 2022-07-18 16:26:37 +03:00
Gani Georgiev 47fc9b1066 normalized null handling in search filters 2022-07-18 14:07:25 +03:00
Gani Georgiev eaf08a5c15 [#151] updated the tests to ensure that the cascaded record files are also deleted 2022-07-18 13:19:07 +03:00
Gani Georgiev 8ef3d4e966 [#151] remove files on cascade deletion 2022-07-18 12:04:27 +03:00
Gani Georgiev 04e0cec32c updated ui/dist 2022-07-18 01:06:55 +03:00
Gani Georgiev 9a231ba7b3 applied some of the changes from #149 2022-07-18 01:03:09 +03:00
Gani Georgiev 7f959011b8 moved field option btns inside the panel 2022-07-18 00:55:53 +03:00
Gani Georgiev 994761b728 normalized the caster to return always non-null value and fixed minor ui issues 2022-07-18 00:16:09 +03:00
Gani Georgiev f19b9e3552 commented golangci-lint action as it is not go1.18+ ready 2022-07-17 22:25:28 +03:00
Gani Georgiev 36783b8f04 added golangci-lint action 2022-07-17 22:20:40 +03:00
Gani Georgiev a076cc906f [#147] added CONTRIBUTING.md 2022-07-17 20:33:12 +03:00
Gani Georgiev b1a30f4050 fixed figure tag typo 2022-07-16 11:57:31 +03:00
Gani Georgiev 4506fb17e9 adding video presets to the file options 2022-07-16 11:54:54 +03:00
Gani Georgiev 72f72bc84f added invalid form-group styles 2022-07-16 10:40:04 +03:00
Gani Georgiev 7fd5102fb5 updated EmailOptions domain input tooltips 2022-07-16 10:39:19 +03:00
Gani Georgiev d6bdc51009 changed the storage slide panel transition to local 2022-07-16 10:38:47 +03:00
195 changed files with 7716 additions and 2618 deletions
+9 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '1.19.0-beta.1'
go-version: '>=1.19.0'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
@@ -29,6 +29,14 @@ jobs:
- name: Build Admin dashboard UI
run: npm --prefix=./ui ci && npm --prefix=./ui run build
# The prebuilt golangci-lint doesn't support go 1.18+ yet
# https://github.com/golangci/golangci-lint/issues/2649
# - name: Run linter
# uses: golangci/golangci-lint-action@v3
- name: Run tests
run: go test ./...
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v3
with:
+10 -3
View File
@@ -20,13 +20,20 @@ builds:
goarch:
- amd64
- arm64
- arm
goarm:
- 7
ignore:
- goos: windows
goarch: arm
- goos: darwin
goarch: arm
release:
draft: true
archives:
-
format: zip
- format: zip
files:
- LICENSE*
- CHANGELOG*
@@ -35,7 +42,7 @@ checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
name_template: '{{ incpatch .Version }}-next'
changelog:
sort: asc
+80
View File
@@ -0,0 +1,80 @@
Contributing to PocketBase
======================================================================
Thanks for taking the time to improve PocketBase!
This document describes how to prepare a PR for a change in the main repository.
- [Prerequisites](#prerequisites)
- [Making changes in the Go code](#making-changes-in-the-go-code)
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
## Prerequisites
- Go 1.18+ (for making changes in the Go code)
- Node 16+ (for making changes in the Admin UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
```
git clone https://github.com/your_username/pocketbase.git
```
> It is recommended to create a new branch from master for each of your bugfixes and features.
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
## Making changes in the Go code
PocketBase is a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
1. Navigate to `examples/base`
2. Run `go run main.go serve`
This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it!
**Before making a PR to the main repository, it is a good idea to:**
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
To run the tests, you could execute (while in the root project directory):
```sh
go test ./...
# or using the Makefile
make test
```
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
```sh
golangci-lint run -c ./golangci.yml ./...
# or using the Makefile
make lint
```
## Making changes in the Admin UI
PocketBase Admin UI is a single-page application (SPA) built with Svelte and Vite.
To start the Admin UI:
1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies
3. Start vite's dev server
```sh
npm run dev
```
You could open the browser and access the running Admin UI at `http://localhost:3000`.
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
+5 -4
View File
@@ -29,7 +29,7 @@
The easiest way to interact with the API is to use one of the official SDK clients:
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
- **Dart** - _soon_
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview
@@ -91,7 +91,7 @@ Running/building the application is the same as for any other Go program, aka. j
**PocketBase embeds SQLite, but doesn't require CGO.**
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` enviroment variable to `1` or `0` respectively.
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively.
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
@@ -120,8 +120,9 @@ PocketBase is free and open source project licensed under the [MIT License](LICE
You could help continuing its development by:
- [Suggest new features, report issues and fix bugs](https://github.com/pocketbase/pocketbase/issues)
- [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us)
> Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
> Please note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
so all feature requests will be first aligned with what we need for Presentator v3.
+24 -8
View File
@@ -4,11 +4,14 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAdminAuth(t *testing.T) {
@@ -92,6 +95,7 @@ func TestAdminRequestPasswordReset(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins/request-password-reset",
Body: strings.NewReader(`{"email":"missing@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
},
{
@@ -99,22 +103,34 @@ func TestAdminRequestPasswordReset(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
// usually this events are fired but since the submit is
// executed in a separate go routine they are fired async
// ExpectedEvents: map[string]int{
// "OnModelBeforeUpdate": 1,
// "OnModelAfterUpdate": 1,
// "OnMailerBeforeUserResetPasswordSend:1": 1,
// "OnMailerAfterUserResetPasswordSend:1": 1,
// },
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeAdminResetPasswordSend": 1,
"OnMailerAfterAdminResetPasswordSend": 1,
},
},
{
Name: "existing admin (after already sent)",
Method: http.MethodPost,
Url: "/api/admins/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// simulate recent password request
admin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
admin.LastResetSentAt = types.NowDateTime()
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
if err := dao.Save(admin); err != nil {
t.Fatal(err)
}
},
},
}
+25 -13
View File
@@ -18,6 +18,8 @@ import (
"github.com/spf13/cast"
)
const trailedAdminPath = "/_/"
// InitApi creates a configured echo instance with registered
// system and app specific routes and middlewares.
func InitApi(app core.App) (*echo.Echo, error) {
@@ -25,7 +27,12 @@ func InitApi(app core.App) (*echo.Echo, error) {
e.Debug = app.IsDebug()
// default middlewares
e.Pre(middleware.RemoveTrailingSlash())
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
Skipper: func(c echo.Context) bool {
// ignore Admin UI route(s)
return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath)
},
}))
e.Use(middleware.Recover())
e.Use(middleware.Secure())
e.Use(LoadAuthContext(app))
@@ -128,21 +135,20 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.H
// bindStaticAdminUI registers the endpoints that serves the static admin UI.
func bindStaticAdminUI(app core.App, e *echo.Echo) error {
// serves /ui/dist/index.html file
// (explicit route is used to avoid conflicts with `RemoveTrailingSlash` middleware)
e.FileFS(
"/_",
"index.html",
ui.DistIndexHTML,
middleware.Gzip(),
installerRedirect(app),
// redirect to trailing slash to ensure that relative urls will still work properly
e.GET(
strings.TrimRight(trailedAdminPath, "/"),
func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath)
},
)
// serves static files from the /ui/dist directory
// (similar to echo.StaticFS but with gzip middleware enabled)
e.GET(
"/_/*",
StaticDirectoryHandler(ui.DistDirFS, false),
trailedAdminPath+"*",
echo.StaticDirectoryHandler(ui.DistDirFS, false),
installerRedirect(app),
middleware.Gzip(),
)
@@ -175,6 +181,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// skip redirect checks for non-root level index.html requests
path := c.Request().URL.Path
if path != trailedAdminPath && path != trailedAdminPath+"index.html" {
return next(c)
}
// load into cache (if not already)
if !app.Cache().Has(totalAdminsCacheKey) {
if err := updateTotalAdminsCache(app); err != nil {
@@ -188,12 +200,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
if totalAdmins == 0 && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, "/_/?installer#")
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page
return c.Redirect(http.StatusTemporaryRedirect, "/_/#/")
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/")
}
return next(c)
+31 -19
View File
@@ -1,8 +1,6 @@
package apis
import (
"errors"
"log"
"net/http"
"github.com/labstack/echo/v5"
@@ -23,6 +21,7 @@ func BindCollectionApi(app core.App, rg *echo.Group) {
subGroup.GET("/:collection", api.view)
subGroup.PATCH("/:collection", api.update)
subGroup.DELETE("/:collection", api.delete)
subGroup.PUT("/import", api.bulkImport)
}
type collectionApi struct {
@@ -160,13 +159,6 @@ func (api *collectionApi) delete(c echo.Context) error {
return rest.NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
}
// try to delete the collection files
if err := api.deleteCollectionFiles(e.Collection); err != nil && api.app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
@@ -177,17 +169,37 @@ func (api *collectionApi) delete(c echo.Context) error {
return handlerErr
}
func (api *collectionApi) deleteCollectionFiles(collection *models.Collection) error {
fs, err := api.app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
func (api *collectionApi) bulkImport(c echo.Context) error {
form := forms.NewCollectionsImport(api.app)
failed := fs.DeletePrefix(collection.BaseFilesPath())
if len(failed) > 0 {
return errors.New("Failed to delete all record files.")
// load request data
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
return nil
event := &core.CollectionsImportEvent{
HttpContext: c,
Collections: form.Collections,
}
// import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnCollectionsAfterImportRequest().Trigger(event)
}
return submitErr
}
+265
View File
@@ -5,6 +5,8 @@ import (
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -440,3 +442,266 @@ func TestCollectionUpdate(t *testing.T) {
scenario.Test(t)
}
}
func TestCollectionImport(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPut,
Url: "/api/collections/import",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPut,
Url: "/api/collections/import",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + empty collections",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{"collections":[]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"validation_required"`,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
}
},
},
{
Name: "authorized as admin + trying to delete system collections",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{"deleteMissing": true, "collections":[{"name": "test123"}]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"collections_import_failure"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
}
},
},
{
Name: "authorized as admin + collections validator failure",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{"name": "import2"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"collections_import_failure"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeCreate": 2,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
}
},
},
{
Name: "authorized as admin + successful collections save",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnCollectionsAfterImportRequest": 1,
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 7 {
t.Fatalf("Expected %d collections, got %d", 7, len(collections))
}
},
},
{
Name: "authorized as admin + successful collections save and old non-system collections deletion",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"deleteMissing": true,
"collections":[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
}
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "new_import",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 3,
"OnModelAfterDelete": 3,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 3 {
t.Fatalf("Expected %d collections, got %d", 3, len(collections))
}
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ func RequireAdminAuth() echo.MiddlewareFunc {
}
}
// RequireAdminAuthIfAny middleware requires a request to have
// RequireAdminAuthOnlyIfAny middleware requires a request to have
// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
// ONLY if the application has at least 1 existing Admin model.
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
+25 -12
View File
@@ -58,6 +58,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
return err
}
if api.app.IsDebug() {
log.Printf("Realtime connection establisehd: %s\n", client.Id())
}
// signalize established connection (aka. fire "connect" message)
fmt.Fprint(c.Response(), "id:"+client.Id()+"\n")
fmt.Fprint(c.Response(), "event:PB_CONNECT\n")
@@ -206,13 +210,17 @@ func (api *realtimeApi) bindEvents() {
return nil
})
api.app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
api.broadcastRecord("create", e.Record)
api.app.OnModelAfterCreate().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("create", record)
}
return nil
})
api.app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
api.broadcastRecord("update", e.Record)
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("update", record)
}
return nil
})
@@ -288,19 +296,24 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return nil // no subscribers
}
// remove the expand from the broadcasted record because we don't
// know if the clients have access to view the expanded records
cleanRecord := *record
cleanRecord.SetExpand(nil)
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + record.Id): collection.ViewRule,
(collection.Id + "/" + record.Id): collection.ViewRule,
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
}
recordData := &recordData{
data := &recordData{
Action: action,
Record: record,
Record: &cleanRecord,
}
serializedData, err := json.Marshal(recordData)
serializedData, err := json.Marshal(data)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
@@ -314,7 +327,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
continue
}
if !api.canAccessRecord(client, record, rule) {
if !api.canAccessRecord(client, data.Record, rule) {
continue
}
+55 -41
View File
@@ -52,10 +52,9 @@ func (api *recordApi) list(c echo.Context) error {
return rest.NewForbiddenError("Only admins can perform this action.", nil)
}
// forbid user/guest defined non-relational joins (aka. @collection.*)
queryStr := c.QueryString()
if admin == nil && queryStr != "" && (strings.Contains(queryStr, "@collection") || strings.Contains(queryStr, "%40collection")) {
return rest.NewForbiddenError("Only admins can filter by @collection.", nil)
// forbid users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil {
return err
}
requestData := api.exportRequestData(c)
@@ -63,14 +62,15 @@ func (api *recordApi) list(c echo.Context) error {
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection))
Query(api.app.Dao().RecordQuery(collection)).
CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
if admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(queryStr, &rawRecords)
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
if err != nil {
return rest.NewBadRequestError("Invalid filter parameters.", err)
}
@@ -80,13 +80,13 @@ func (api *recordApi) list(c echo.Context) error {
// expand records relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
expandErr := api.app.Dao().ExpandRecords(
failed := api.app.Dao().ExpandRecords(
records,
expands,
api.expandFunc(c, requestData),
)
if expandErr != nil && api.app.IsDebug() {
log.Println("Failed to expand relations: ", expandErr)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
@@ -141,16 +141,14 @@ func (api *recordApi) view(c echo.Context) error {
return rest.NewNotFoundError("", fetchErr)
}
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
expandErr := api.app.Dao().ExpandRecord(
record,
expands,
api.expandFunc(c, requestData),
)
if expandErr != nil && api.app.IsDebug() {
log.Println("Failed to expand relations: ", expandErr)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
record,
strings.Split(c.QueryParam(expandQueryParam), ","),
api.expandFunc(c, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
event := &core.RecordViewEvent{
@@ -226,6 +224,16 @@ func (api *recordApi) create(c echo.Context) error {
return rest.NewBadRequestError("Failed to create record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
api.expandFunc(e.HttpContext, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -296,6 +304,16 @@ func (api *recordApi) update(c echo.Context) error {
return rest.NewBadRequestError("Failed to update record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
api.expandFunc(e.HttpContext, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -356,13 +374,6 @@ func (api *recordApi) delete(c echo.Context) error {
return rest.NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
}
// try to delete the record files
if err := api.deleteRecordFiles(e.Record); err != nil && api.app.IsDebug() {
// non critical error - only log for debug
// (usually could happen due to S3 api limits)
log.Println(err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
@@ -373,21 +384,6 @@ func (api *recordApi) delete(c echo.Context) error {
return handlerErr
}
func (api *recordApi) deleteRecordFiles(record *models.Record) error {
fs, err := api.app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
failed := fs.DeletePrefix(record.BaseFilesPath())
if len(failed) > 0 {
return fmt.Errorf("Failed to delete %d record files.", len(failed))
}
return nil
}
func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
result := map[string]any{}
queryParams := map[string]any{}
@@ -411,6 +407,24 @@ func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
return result
}
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // admins are allowed to query everything
}
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
forbiddenFields := []string{"@collection.", "@request."}
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
+170 -32
View File
@@ -127,7 +127,7 @@ func TestRecordsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "expand",
Name: "expand relations",
Method: http.MethodGet,
Url: "/api/collections/demo2/records?expand=manyrels,onerel&perPage=2&sort=created",
RequestHeaders: map[string]string{
@@ -139,17 +139,17 @@ func TestRecordsList(t *testing.T) {
`"perPage":2`,
`"totalItems":2`,
`"items":[{`,
`"@expand":{`,
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"manyrels":[{`,
`"manyrels":[]`,
`"rel_cascade":"`,
`"rel_cascade":null`,
`"cascaderel":"`,
`"onerel":{"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc","@collectionName":"demo",`,
`"json":[1,2,3]`,
`"select":["a","b"]`,
`"select":[]`,
`"user":null`,
`"user":""`,
`"bool":true`,
`"number":456`,
`"user":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
@@ -357,6 +357,7 @@ func TestRecordView(t *testing.T) {
`"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
`"@collectionName":"demo2"`,
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"@expand":{`,
`"manyrels":[{`,
`"onerel":{`,
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
@@ -455,11 +456,13 @@ func TestRecordDelete(t *testing.T) {
"OnRecordAfterDeleteRequest": 1,
"OnModelAfterUpdate": 1, // nullify related record
"OnModelBeforeUpdate": 1, // nullify related record
"OnModelBeforeDelete": 2, // +1 cascade delete related record
"OnModelAfterDelete": 2, // +1 cascade delete related record
"OnModelBeforeDelete": 3, // +2 cascade delete related records
"OnModelAfterDelete": 3, // +2 cascade delete related records
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
},
},
{
@@ -475,11 +478,13 @@ func TestRecordDelete(t *testing.T) {
"OnRecordAfterDeleteRequest": 1,
"OnModelAfterUpdate": 1, // nullify related record
"OnModelBeforeUpdate": 1, // nullify related record
"OnModelBeforeDelete": 2, // +1 cascade delete related record
"OnModelAfterDelete": 2, // +1 cascade delete related record
"OnModelBeforeDelete": 3, // +2 cascade delete related records
"OnModelAfterDelete": 3, // +2 cascade delete related records
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
},
},
{
@@ -554,8 +559,8 @@ func TestRecordDelete(t *testing.T) {
"OnRecordAfterDeleteRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeDelete": 2,
"OnModelAfterDelete": 2,
"OnModelBeforeDelete": 3,
"OnModelAfterDelete": 3,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
recId := "63c2ab80-84ab-4057-a592-4604a731f78f"
@@ -565,6 +570,7 @@ func TestRecordDelete(t *testing.T) {
t.Errorf("Expected record %s to be cascade deleted", recId)
}
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
},
},
@@ -646,11 +652,12 @@ func TestRecordCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"rel_cascade": "577bd676-aacb-4072-b7da-99d00ee210a4",
"cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels": ["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text": "test123",
"bool": "false"
"bool": "false",
"number": 1
}`),
RequestHeaders: map[string]string{
// test@example.com
@@ -660,15 +667,17 @@ func TestRecordCreate(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "user submit in restricted collection (rule pass check)",
Name: "user submit in restricted collection (rule pass check) + expand relations",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Url: "/api/collections/demo2/records?expand=missing,onerel,manyrels,selfrel",
Body: strings.NewReader(`{
"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4",
"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
"text":"test123",
"bool":true
"bool":true,
"number":1
}`),
RequestHeaders: map[string]string{
// test3@example.com
@@ -677,11 +686,21 @@ func TestRecordCreate(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"text":"test123"`,
`"bool":true`,
`"number":1`,
`"@expand":{`,
`"selfrel":{`,
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
},
NotExpectedContent: []string{
// user don't have access to view the below expands
`"manyrels":[{`,
`"onerel":{`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
@@ -691,15 +710,17 @@ func TestRecordCreate(t *testing.T) {
},
},
{
Name: "admin submit in restricted collection (rule skip check)",
Name: "admin submit in restricted collection (rule skip check) + expand relations",
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Url: "/api/collections/demo2/records?expand=missing,onerel,manyrels,selfrel",
Body: strings.NewReader(`{
"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
"cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text":"test123",
"bool":false
"selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9",
"text": "test123",
"bool": false,
"number": 1
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
@@ -707,11 +728,20 @@ func TestRecordCreate(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"text":"test123"`,
`"bool":false`,
`"number":1`,
`"@expand":{`,
`"manyrels":[{`,
`"onerel":{`,
`"selfrel":{`,
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"@collectionName":"demo"`,
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
@@ -720,6 +750,62 @@ func TestRecordCreate(t *testing.T) {
"OnModelAfterCreate": 1,
},
},
{
Name: "invalid custom insertion id (less than 15 chars)",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "12345678901234",
"title": "test"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"id":{"code":"validation_length_invalid"`,
},
},
{
Name: "invalid custom insertion id (more than 15 chars)",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "1234567890123456",
"title": "test"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"id":{"code":"validation_length_invalid"`,
},
},
{
Name: "valid custom insertion id (exactly 15 chars)",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "123456789012345",
"title": "test"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"123456789012345"`,
`"title":"test"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
{
Name: "submit via multipart form data",
Method: http.MethodPost,
@@ -835,12 +921,13 @@ func TestRecordUpdate(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "user submit in restricted collection (rule pass check)",
Name: "user submit in restricted collection (rule pass check) + expand relations",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
Body: strings.NewReader(`{
"text":"test_new",
"bool":false
"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
"bool":true
}`),
RequestHeaders: map[string]string{
// test3@example.com
@@ -849,11 +936,19 @@ func TestRecordUpdate(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"bool":false`,
`"bool":true`,
`"text":"test_new"`,
`"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"@expand":{`,
`"selfrel":{`,
},
NotExpectedContent: []string{
// user don't have access to view the below expands
`"manyrels":[{`,
`"onerel":{`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
@@ -863,11 +958,44 @@ func TestRecordUpdate(t *testing.T) {
},
},
{
Name: "admin submit in restricted collection (rule skip check)",
Name: "user submit in restricted collection (rule pass check) + expand relations (no view rule access when bool is false)",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
Body: strings.NewReader(`{
"text":"test_new"
"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
"bool":false
}`),
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"bool":false`,
`"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
},
NotExpectedContent: []string{
`"@expand":{`,
`"manyrels":[{`, // admin only
`"onerel":{`, // admin only
`"selfrel":{`, // bool=true view rule
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
"OnRecordAfterUpdateRequest": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
},
},
{
Name: "admin submit in restricted collection (rule skip check) + expand relations",
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=onerel,manyrels,selfrel,missing",
Body: strings.NewReader(`{
"text":"test_new",
"number":1,
"selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
@@ -876,6 +1004,16 @@ func TestRecordUpdate(t *testing.T) {
ExpectedContent: []string{
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"text":"test_new"`,
`"number":1`,
`"@expand":{`,
`"manyrels":[{`,
`"onerel":{`,
`"selfrel":{`,
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"@collectionName":"demo"`,
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
`"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
+58 -21
View File
@@ -4,9 +4,12 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestUsersAuthMethods(t *testing.T) {
@@ -132,6 +135,7 @@ func TestUserRequestPasswordReset(t *testing.T) {
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(`{"email":"missing@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
},
{
@@ -139,22 +143,34 @@ func TestUserRequestPasswordReset(t *testing.T) {
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
// usually this events are fired but since the submit is
// executed in a separate go routine they are fired async
// ExpectedEvents: map[string]int{
// "OnModelBeforeUpdate": 1,
// "OnModelAfterUpdate": 1,
// "OnMailerBeforeUserResetPasswordSend": 1,
// "OnMailerAfterUserResetPasswordSend": 1,
// },
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeUserResetPasswordSend": 1,
"OnMailerAfterUserResetPasswordSend": 1,
},
},
{
Name: "existing user (after already sent)",
Method: http.MethodPost,
Url: "/api/users/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// simulate recent password request
user, err := app.Dao().FindUserByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
user.LastResetSentAt = types.NowDateTime()
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
if err := dao.Save(user); err != nil {
t.Fatal(err)
}
},
},
}
@@ -216,50 +232,71 @@ func TestUserConfirmPasswordReset(t *testing.T) {
func TestUserRequestVerification(t *testing.T) {
scenarios := []tests.ApiScenario{
// empty data
{
Name: "empty data",
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
},
// invalid data
{
Name: "invalid data",
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
// missing user
{
Name: "missing user",
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email":"missing@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
},
// existing already verified user
{
Name: "existing already verified user",
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
},
// existing unverified user
{
Name: "existing unverified user",
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email":"test2@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
// usually this events are fired but since the submit is
// executed in a separate go routine they are fired async
// ExpectedEvents: map[string]int{
// "OnModelBeforeUpdate": 1,
// "OnModelAfterUpdate": 1,
// "OnMailerBeforeUserVerificationSend": 1,
// "OnMailerAfterUserVerificationSend": 1,
// },
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeUserVerificationSend": 1,
"OnMailerAfterUserVerificationSend": 1,
},
},
{
Name: "existing unverified user (after already sent)",
Method: http.MethodPost,
Url: "/api/users/request-verification",
Body: strings.NewReader(`{"email":"test2@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// simulate recent verification sent
user, err := app.Dao().FindUserByEmail("test2@example.com")
if err != nil {
t.Fatal(err)
}
user.LastVerificationSentAt = types.NowDateTime()
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
if err := dao.Save(user); err != nil {
t.Fatal(err)
}
},
},
}
+168 -4
View File
@@ -1,12 +1,21 @@
package cmd
import (
"encoding/json"
"fmt"
"log"
"os"
"path"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
)
@@ -15,18 +24,40 @@ import (
func NewMigrateCommand(app core.App) *cobra.Command {
desc := `
Supported arguments are:
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create folder name - creates new migration template file.
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create name [folder] - creates new migration template file.
- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
`
var databaseFlag string
command := &cobra.Command{
Use: "migrate",
Short: "Executes DB migration scripts",
ValidArgs: []string{"up", "down", "create"},
ValidArgs: []string{"up", "down", "create", "collections"},
Long: desc,
Run: func(command *cobra.Command, args []string) {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
// additional commands
// ---
if cmd == "create" {
if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil {
log.Fatal(err)
}
return
}
if cmd == "collections" {
if err := migrateCollectionsHandler(app, args[1:]); err != nil {
log.Fatal(err)
}
return
}
// ---
// normalize
if databaseFlag != "logs" {
databaseFlag = "db"
@@ -75,3 +106,136 @@ func migrationsConnectionsMap(app core.App) map[string]migrationsConnection {
},
}
}
// -------------------------------------------------------------------
// migrate create
// -------------------------------------------------------------------
const defaultMigrateCreateTemplate = `package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
func migrateCreateHandler(template string, args []string) error {
if len(args) < 1 {
return fmt.Errorf("Missing migration file name")
}
name := args[0]
var dir string
if len(args) == 2 {
dir = args[1]
}
if dir == "" {
// If not specified, auto point to the default migrations folder.
//
// NB!
// Since the create command makes sense only during development,
// it is expected the user to be in the app working directory
// and to be using `go run`
wd, err := os.Getwd()
if err != nil {
return err
}
dir = path.Join(wd, "migrations")
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
)
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
// ensure that migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
}
// -------------------------------------------------------------------
// migrate collections
// -------------------------------------------------------------------
const collectionsMigrateCreateTemplate = `package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
// Auto generated migration with the most recent collections configuration.
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := ` + "`" + `%s` + "`" + `
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
// no revert since the configuration on the environment, on which
// the migration was executed, could have changed via the UI/API
return nil
})
}
`
func migrateCollectionsHandler(app core.App, args []string) error {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
dao := daos.New(app.DB())
collections := []*models.Collection{}
if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("Failed to fetch migrations list: %v", err)
}
serialized, err := json.MarshalIndent(collections, "\t\t", "\t")
if err != nil {
return fmt.Errorf("Failed to serialize collections list: %v", err)
}
return migrateCreateHandler(
fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)),
createArgs,
)
}
+2 -2
View File
@@ -28,7 +28,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command := &cobra.Command{
Use: "serve",
Short: "Starts the web server (default to localhost:8090)",
Short: "Starts the web server (default to 127.0.0.1:8090)",
Run: func(command *cobra.Command, args []string) {
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
@@ -123,7 +123,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command.PersistentFlags().StringVar(
&httpAddr,
"http",
"localhost:8090",
"127.0.0.1:8090",
"api HTTP server address",
)
+28 -17
View File
@@ -119,7 +119,7 @@ type App interface {
// sending a password reset email to an admin.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerAfterAdminResetPasswordSend hook is triggered after
@@ -130,7 +130,7 @@ type App interface {
// sending a password reset email to a user.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserResetPasswordSend hook is triggered after
@@ -141,7 +141,7 @@ type App interface {
// sending a verification email to a user.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserVerificationSend hook is triggered after a user
@@ -152,7 +152,7 @@ type App interface {
// sending a confirmation new address email to a a user.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
@@ -192,7 +192,7 @@ type App interface {
//
// Could be used to additionally validate the request data or
// implement completely different persistence behavior
// (returning hook.StopPropagation).
// (returning [hook.StopPropagation]).
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
// OnSettingsAfterUpdateRequest hook is triggered after each
@@ -227,7 +227,7 @@ type App interface {
// Admin create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
// OnAdminAfterCreateRequest hook is triggered after each
@@ -238,7 +238,7 @@ type App interface {
// Admin update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
// OnAdminAfterUpdateRequest hook is triggered after each
@@ -249,7 +249,7 @@ type App interface {
// Admin delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
// OnAdminAfterDeleteRequest hook is triggered after each
@@ -281,7 +281,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
// OnUserAfterCreateRequest hook is triggered after each
@@ -292,7 +292,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
// OnUserAfterUpdateRequest hook is triggered after each
@@ -303,7 +303,7 @@ type App interface {
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]
// OnUserAfterDeleteRequest hook is triggered after each
@@ -346,7 +346,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
// OnRecordAfterCreateRequest hook is triggered after each
@@ -357,7 +357,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
// OnRecordAfterUpdateRequest hook is triggered after each
@@ -368,7 +368,7 @@ type App interface {
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
// OnRecordAfterDeleteRequest hook is triggered after each
@@ -393,7 +393,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
// OnCollectionAfterCreateRequest hook is triggered after each
@@ -404,7 +404,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
// OnCollectionAfterUpdateRequest hook is triggered after each
@@ -415,10 +415,21 @@ type App interface {
// Collection delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionAfterDeleteRequest hook is triggered after each
// successful API Collection delete request.
OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionsBeforeImportRequest hook is triggered before each API
// collections import request (after request data load and before the actual import).
//
// Could be used to additionally validate the imported collections or
// to implement completely different import behavior (returning [hook.StopPropagation]).
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
// OnCollectionsAfterImportRequest hook is triggered after each
// successful API collections import request.
OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent]
}
+65 -17
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"log"
"os"
"path/filepath"
"time"
@@ -108,14 +109,16 @@ type BaseApp struct {
onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent]
// collection api event hooks
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionsBeforeImportRequest *hook.Hook[*CollectionsImportEvent]
onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent]
}
// NewBaseApp creates and returns a new BaseApp instance
@@ -123,7 +126,7 @@ type BaseApp struct {
//
// To initialize the app, you need to call `app.Bootsrap()`.
func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
return &BaseApp{
app := &BaseApp{
dataDir: dataDir,
isDebug: isDebug,
encryptionEnv: encryptionEnv,
@@ -200,15 +203,21 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onRecordAfterDeleteRequest: &hook.Hook[*RecordDeleteEvent]{},
// collection API event hooks
onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{},
onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{},
onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{},
onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{},
onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionsBeforeImportRequest: &hook.Hook[*CollectionsImportEvent]{},
onCollectionsAfterImportRequest: &hook.Hook[*CollectionsImportEvent]{},
}
app.registerDefaultHooks()
return app
}
// Bootstrap initializes the application
@@ -342,6 +351,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
app.settings.S3.Endpoint,
app.settings.S3.AccessKey,
app.settings.S3.Secret,
app.settings.S3.ForcePathStyle,
)
}
@@ -681,6 +691,14 @@ func (app *BaseApp) OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDelet
return app.onCollectionAfterDeleteRequest
}
func (app *BaseApp) OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] {
return app.onCollectionsBeforeImportRequest
}
func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] {
return app.onCollectionsAfterImportRequest
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
@@ -750,3 +768,33 @@ func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao {
return dao
}
func (app *BaseApp) registerDefaultHooks() {
deletePrefix := func(prefix string) error {
fs, err := app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
failed := fs.DeletePrefix(prefix)
if len(failed) > 0 {
return errors.New("Failed to delete the files at " + prefix)
}
return nil
}
// delete storage files from deleted Collection, Records, etc.
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
}
return nil
})
}
+5
View File
@@ -216,6 +216,11 @@ type CollectionDeleteEvent struct {
Collection *models.Collection
}
type CollectionsImportEvent struct {
HttpContext echo.Context
Collections []*models.Collection
}
// -------------------------------------------------------------------
// File API events data
// -------------------------------------------------------------------
+7 -6
View File
@@ -244,12 +244,13 @@ func (c SmtpConfig) Validate() error {
// -------------------------------------------------------------------
type S3Config struct {
Enabled bool `form:"enabled" json:"enabled"`
Bucket string `form:"bucket" json:"bucket"`
Region string `form:"region" json:"region"`
Endpoint string `form:"endpoint" json:"endpoint"`
AccessKey string `form:"accessKey" json:"accessKey"`
Secret string `form:"secret" json:"secret"`
Enabled bool `form:"enabled" json:"enabled"`
Bucket string `form:"bucket" json:"bucket"`
Region string `form:"region" json:"region"`
Endpoint string `form:"endpoint" json:"endpoint"`
AccessKey string `form:"accessKey" json:"accessKey"`
Secret string `form:"secret" json:"secret"`
ForcePathStyle bool `form:"forcePathStyle" json:"forcePathStyle"`
}
// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
+1 -1
View File
@@ -172,7 +172,7 @@ func TestSettingsRedactClone(t *testing.T) {
t.Fatal(err)
}
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","senderName":"Support","senderAddress":"support@example.com","userVerificationUrl":"%APP_URL%/_/#/users/confirm-verification/%TOKEN%","userResetPasswordUrl":"%APP_URL%/_/#/users/confirm-password-reset/%TOKEN%","userConfirmEmailChangeUrl":"%APP_URL%/_/#/users/confirm-email-change/%TOKEN%"},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******"},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","senderName":"Support","senderAddress":"support@example.com","userVerificationUrl":"%APP_URL%/_/#/users/confirm-verification/%TOKEN%","userResetPasswordUrl":"%APP_URL%/_/#/users/confirm-password-reset/%TOKEN%","userConfirmEmailChangeUrl":"%APP_URL%/_/#/users/confirm-email-change/%TOKEN%"},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
if encodedStr := string(encoded); encodedStr != expected {
t.Fatalf("Expected %v, got \n%v", expected, encodedStr)
+65 -33
View File
@@ -49,6 +49,12 @@ func (dao *Dao) FindById(m models.Model, id string) error {
return dao.ModelQuery(m).Where(dbx.HashExp{"id": id}).Limit(1).One(m)
}
type afterCallGroup struct {
Action string
EventDao *Dao
Model models.Model
}
// RunInTransaction wraps fn into a transaction.
//
// It is safe to nest RunInTransaction calls.
@@ -59,45 +65,61 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
// so execute the function within the current transaction
return fn(dao)
case *dbx.DB:
return txOrDB.Transactional(func(tx *dbx.Tx) error {
afterCalls := []afterCallGroup{}
txError := txOrDB.Transactional(func(tx *dbx.Tx) error {
txDao := New(tx)
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeCreateFunc != nil {
if dao.BeforeCreateFunc != nil {
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeCreateFunc(eventDao, m)
}
return nil
}
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(eventDao, m)
}
}
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeUpdateFunc != nil {
if dao.BeforeUpdateFunc != nil {
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeUpdateFunc(eventDao, m)
}
return nil
}
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterUpdateFunc != nil {
dao.AfterUpdateFunc(eventDao, m)
}
}
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeDeleteFunc != nil {
if dao.BeforeDeleteFunc != nil {
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeDeleteFunc(eventDao, m)
}
return nil
}
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterDeleteFunc != nil {
dao.AfterDeleteFunc(eventDao, m)
if dao.AfterCreateFunc != nil {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
}
}
if dao.AfterUpdateFunc != nil {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
}
}
if dao.AfterDeleteFunc != nil {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
}
}
return fn(txDao)
})
if txError == nil {
// execute after event calls on successful transaction
for _, call := range afterCalls {
switch call.Action {
case "create":
dao.AfterCreateFunc(call.EventDao, call.Model)
case "update":
dao.AfterUpdateFunc(call.EventDao, call.Model)
case "delete":
dao.AfterDeleteFunc(call.EventDao, call.Model)
}
}
}
return txError
}
return errors.New("Failed to start transaction (unknown dao.db)")
@@ -115,9 +137,8 @@ func (dao *Dao) Delete(m models.Model) error {
}
}
deleteErr := dao.db.Model(m).Delete()
if deleteErr != nil {
return deleteErr
if err := dao.db.Model(m).Delete(); err != nil {
return err
}
if dao.AfterDeleteFunc != nil {
@@ -129,11 +150,11 @@ func (dao *Dao) Delete(m models.Model) error {
// Save upserts (update or create if primary key is not set) the provided model.
func (dao *Dao) Save(m models.Model) error {
if m.HasId() {
return dao.update(m)
if m.IsNew() {
return dao.create(m)
}
return dao.create(m)
return dao.update(m)
}
func (dao *Dao) update(m models.Model) error {
@@ -141,6 +162,10 @@ func (dao *Dao) update(m models.Model) error {
return errors.New("ID is not set")
}
if m.GetCreated().IsZero() {
m.RefreshCreated()
}
m.RefreshUpdated()
if dao.BeforeUpdateFunc != nil {
@@ -162,8 +187,7 @@ func (dao *Dao) update(m models.Model) error {
return err
}
} else {
err := dao.db.Model(m).Update()
if err != nil {
if err := dao.db.Model(m).Update(); err != nil {
return err
}
}
@@ -181,6 +205,9 @@ func (dao *Dao) create(m models.Model) error {
m.RefreshId()
}
// mark the model as "new" since the model now always has an ID
m.MarkAsNew()
if m.GetCreated().IsZero() {
m.RefreshCreated()
}
@@ -197,18 +224,23 @@ func (dao *Dao) create(m models.Model) error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
if _, ok := dataMap["id"]; !ok {
dataMap["id"] = m.GetId()
}
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
if err != nil {
return err
}
} else {
err := dao.db.Model(m).Insert()
if err != nil {
if err := dao.db.Model(m).Insert(); err != nil {
return err
}
}
// clears the "new" model flag
m.UnmarkAsNew()
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(dao, m)
}
+269 -10
View File
@@ -158,6 +158,33 @@ func TestDaoSaveCreate(t *testing.T) {
}
}
func TestDaoSaveWithInsertId(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model := &models.Admin{}
model.Id = "test"
model.Email = "test_new@example.com"
model.MarkAsNew()
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
// refresh
model, _ = testApp.Dao().FindAdminById("test")
if model == nil {
t.Fatal("Failed to find admin with id 'test'")
}
expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"}
for _, h := range expectedHooks {
if v, ok := testApp.EventCalls[h]; !ok || v != 1 {
t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v)
}
}
}
func TestDaoSaveUpdate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -184,6 +211,61 @@ func TestDaoSaveUpdate(t *testing.T) {
}
}
type dummyColumnValueMapper struct {
models.Admin
}
func (a *dummyColumnValueMapper) ColumnValueMap() map[string]any {
return map[string]any{
"email": a.Email,
"passwordHash": a.PasswordHash,
"tokenKey": "custom_token_key",
}
}
func TestDaoSaveWithColumnValueMapper(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model := &dummyColumnValueMapper{}
model.Id = "test_mapped_id" // explicitly set an id
model.Email = "test_mapped_create@example.com"
model.TokenKey = "test_unmapped_token_key" // not used in the map
model.SetPassword("123456")
model.MarkAsNew()
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
createdModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
if createdModel == nil {
t.Fatal("[create] Failed to find model with id 'test_mapped_id'")
}
if createdModel.Email != model.Email {
t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
}
if createdModel.TokenKey != "custom_token_key" {
t.Fatalf("Expected model with tokenKey %q, got %q", "custom_token_key", createdModel.TokenKey)
}
model.Email = "test_mapped_update@example.com"
model.Avatar = 9 // not mapped and expect to be ignored
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
updatedModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
if updatedModel == nil {
t.Fatal("[update] Failed to find model with id 'test_mapped_id'")
}
if updatedModel.Email != model.Email {
t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
}
if updatedModel.Avatar != 0 {
t.Fatalf("Expected model avatar 0, got %v", updatedModel.Avatar)
}
}
func TestDaoDelete(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -211,35 +293,212 @@ func TestDaoBeforeHooksError(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
testApp.Dao().BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_create")
}
testApp.Dao().BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_update")
}
testApp.Dao().BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_delete")
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
// try to create
// test create error
// ---
newModel := &models.Admin{}
newModel.Email = "test_new@example.com"
if err := testApp.Dao().Save(newModel); err.Error() != "before_create" {
if err := baseDao.Save(newModel); err.Error() != "before_create" {
t.Fatalf("Expected before_create error, got %v", err)
}
// try to update
// test update error
// ---
if err := testApp.Dao().Save(existingModel); err.Error() != "before_update" {
if err := baseDao.Save(existingModel); err.Error() != "before_update" {
t.Fatalf("Expected before_update error, got %v", err)
}
// try to delete
// test delete error
// ---
if err := testApp.Dao().Delete(existingModel); err.Error() != "before_delete" {
if err := baseDao.Delete(existingModel); err.Error() != "before_delete" {
t.Fatalf("Expected before_delete error, got %v", err)
}
}
func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
beforeCreateFuncCalls := 0
beforeUpdateFuncCalls := 0
beforeDeleteFuncCalls := 0
afterCreateFuncCalls := 0
afterUpdateFuncCalls := 0
afterDeleteFuncCalls := 0
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeCreateFuncCalls++
return nil
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeUpdateFuncCalls++
return nil
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeDeleteFuncCalls++
return nil
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
afterCreateFuncCalls++
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
afterUpdateFuncCalls++
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
afterDeleteFuncCalls++
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
// test create
// ---
newModel := &models.Admin{}
newModel.Email = "test_new1@example.com"
newModel.SetPassword("123456")
if err := txDao2.Save(newModel); err != nil {
t.Fatal(err)
}
// test update (twice)
// ---
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
// test delete
// ---
if err := txDao2.Delete(existingModel); err != nil {
t.Fatal(err)
}
return errors.New("test_tx_error")
})
})
if beforeCreateFuncCalls != 1 {
t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
}
if beforeUpdateFuncCalls != 2 {
t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
}
if beforeDeleteFuncCalls != 1 {
t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
}
if afterCreateFuncCalls != 0 {
t.Fatalf("Expected afterCreateFuncCalls to be called 0 times, got %d", afterCreateFuncCalls)
}
if afterUpdateFuncCalls != 0 {
t.Fatalf("Expected afterUpdateFuncCalls to be called 0 times, got %d", afterUpdateFuncCalls)
}
if afterDeleteFuncCalls != 0 {
t.Fatalf("Expected afterDeleteFuncCalls to be called 0 times, got %d", afterDeleteFuncCalls)
}
}
func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
beforeCreateFuncCalls := 0
beforeUpdateFuncCalls := 0
beforeDeleteFuncCalls := 0
afterCreateFuncCalls := 0
afterUpdateFuncCalls := 0
afterDeleteFuncCalls := 0
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeCreateFuncCalls++
return nil
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeUpdateFuncCalls++
return nil
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeDeleteFuncCalls++
return nil
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
afterCreateFuncCalls++
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
afterUpdateFuncCalls++
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
afterDeleteFuncCalls++
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
// test create
// ---
newModel := &models.Admin{}
newModel.Email = "test_new1@example.com"
newModel.SetPassword("123456")
if err := txDao2.Save(newModel); err != nil {
t.Fatal(err)
}
// test update (twice)
// ---
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
// test delete
// ---
if err := txDao2.Delete(existingModel); err != nil {
t.Fatal(err)
}
return nil
})
})
if beforeCreateFuncCalls != 1 {
t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
}
if beforeUpdateFuncCalls != 2 {
t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
}
if beforeDeleteFuncCalls != 1 {
t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
}
if afterCreateFuncCalls != 1 {
t.Fatalf("Expected afterCreateFuncCalls to be called 1 times, got %d", afterCreateFuncCalls)
}
if afterUpdateFuncCalls != 2 {
t.Fatalf("Expected afterUpdateFuncCalls to be called 2 times, got %d", afterUpdateFuncCalls)
}
if afterDeleteFuncCalls != 1 {
t.Fatalf("Expected afterDeleteFuncCalls to be called 1 times, got %d", afterDeleteFuncCalls)
}
}
+115 -3
View File
@@ -113,7 +113,7 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
// - is referenced as part of a relation field in another collection
func (dao *Dao) DeleteCollection(collection *models.Collection) error {
if collection.System {
return errors.New("System collections cannot be deleted.")
return fmt.Errorf("System collection %q cannot be deleted.", collection.Name)
}
// ensure that there aren't any existing references.
@@ -123,7 +123,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
return err
}
if total := len(result); total > 0 {
return fmt.Errorf("The collection has external relation field references (%d).", total)
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total)
}
return dao.RunInTransaction(func(txDao *Dao) error {
@@ -141,7 +141,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection
if collection.HasId() {
if !collection.IsNew() {
// get the existing collection state to compare with the new one
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
var findErr error
@@ -161,3 +161,115 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
return txDao.SyncRecordTableSchema(collection, oldCollection)
})
}
// ImportCollections imports the provided collections list within a single transaction.
//
// NB1! If deleteMissing is set, all local collections and schema fields, that are not present
// in the imported configuration, WILL BE DELETED (including their related records data).
//
// NB2! This method doesn't perform validations on the imported collections data!
// If you need validations, use [forms.CollectionsImport].
func (dao *Dao) ImportCollections(
importedCollections []*models.Collection,
deleteMissing bool,
beforeRecordsSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
) error {
if len(importedCollections) == 0 {
return errors.New("No collections to import")
}
return dao.RunInTransaction(func(txDao *Dao) error {
existingCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&existingCollections); err != nil {
return err
}
mappedExisting := make(map[string]*models.Collection, len(existingCollections))
for _, existing := range existingCollections {
mappedExisting[existing.GetId()] = existing
}
mappedImported := make(map[string]*models.Collection, len(importedCollections))
for _, imported := range importedCollections {
// generate id if not set
if !imported.HasId() {
imported.MarkAsNew()
imported.RefreshId()
}
if existing, ok := mappedExisting[imported.GetId()]; ok {
// preserve original created date
if !existing.Created.IsZero() {
imported.Created = existing.Created
}
// extend existing schema
if !deleteMissing {
schema, _ := existing.Schema.Clone()
for _, f := range imported.Schema.Fields() {
schema.AddField(f) // add or replace
}
imported.Schema = *schema
}
} else {
imported.MarkAsNew()
}
mappedImported[imported.GetId()] = imported
}
// delete old collections not available in the new configuration
// (before saving the imports in case a deleted collection name is being reused)
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
if existing.System {
return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
}
// delete the collection
if err := txDao.Delete(existing); err != nil {
return err
}
}
}
// upsert imported collections
for _, imported := range importedCollections {
if err := txDao.Save(imported); err != nil {
return err
}
}
if beforeRecordsSync != nil {
if err := beforeRecordsSync(txDao, mappedImported, mappedExisting); err != nil {
return err
}
}
// delete the record tables of the deleted collections
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
}
}
// sync the upserted collections with the related records table
for _, imported := range importedCollections {
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
}
}
return nil
})
}
+315 -1
View File
@@ -1,8 +1,11 @@
package daos_test
import (
"encoding/json"
"errors"
"testing"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
@@ -113,7 +116,7 @@ func TestFindCollectionReferences(t *testing.T) {
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
}
expectedFields := []string{"onerel", "manyrels", "rel_cascade"}
expectedFields := []string{"onerel", "manyrels", "cascaderel"}
for col, fields := range result {
if col.Name != "demo2" {
@@ -250,3 +253,314 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
}
}
func TestImportCollections(t *testing.T) {
scenarios := []struct {
name string
jsonData string
deleteMissing bool
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
expectError bool
expectCollectionsCount int
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
}{
{
name: "empty collections",
jsonData: `[]`,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "check db constraints",
jsonData: `[
{"name": "import_test", "schema": []}
]`,
deleteMissing: false,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "minimal collection import",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
},
{
name: "minimal collection import + failed beforeRecordsSync",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
return errors.New("test_error")
},
deleteMissing: false,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "minimal collection import + successful beforeRecordsSync",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
return nil
},
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
},
{
name: "new + update + delete system collection",
jsonData: `[
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: true,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "new + update + delete non-system collection",
jsonData: `[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
}
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"id": "test_deleted_collection_name_reuse",
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: true,
expectError: false,
expectCollectionsCount: 3,
},
{
name: "test with deleteMissing: false",
jsonData: `[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":true
}
},
{
"id":"abcd_import",
"name":"new_field",
"type":"bool"
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
},
{
"id":"_2hlxbmp",
"name":"field_with_duplicate_id",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
},
{
"id":"abcd_import",
"name":"new_field",
"type":"text"
}
]
},
{
"name": "new_import",
"schema": [
{
"id":"abcd_import",
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
"profiles": 6,
"demo": 3,
"demo2": 14,
"demo3": 1,
"demo4": 6,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
collection, err := testApp.Dao().FindCollectionByNameOrId(name)
if err != nil {
t.Fatal(err)
}
if totalFields := len(collection.Schema.Fields()); totalFields != expectedCount {
t.Errorf("Expected %d %q fields, got %d", expectedCount, collection.Name, totalFields)
}
}
},
},
}
for _, scenario := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
importedCollections := []*models.Collection{}
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections)
if loadErr != nil {
t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr)
continue
}
err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != scenario.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections))
}
if scenario.afterTestFunc != nil {
scenario.afterTestFunc(testApp, collections)
}
}
}
+1 -3
View File
@@ -40,7 +40,7 @@ func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) err
param = &models.Param{Key: key}
}
var normalizedValue any
normalizedValue := value
// encrypt if optEncryptionKey is set
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
@@ -55,8 +55,6 @@ func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) err
}
normalizedValue = encryptVal
} else {
normalizedValue = value
}
encodedValue := types.JsonRaw{}
+32 -6
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -149,6 +150,10 @@ func (dao *Dao) IsRecordValueUnique(
// FindUserRelatedRecords returns all records that has a reference
// to the provided User model (via the user shema field).
func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) {
if user.Id == "" {
return []*models.Record{}, nil
}
collections, err := dao.FindCollectionsWithUserFields()
if err != nil {
return nil, err
@@ -329,23 +334,44 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
// check for new or renamed columns
toRename := map[string]string{}
for _, field := range newSchema.Fields() {
oldField := oldSchema.GetFieldById(field.Id)
if oldField != nil {
// rename
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, field.Name).Execute()
// Note:
// We are using a temporary column name when adding or renaming columns
// to ensure that there are no name collisions in case there is
// names switch/reuse of existing columns (eg. name, title -> title, name).
// This way we are always doing 1 more rename operation but it provides better dev experience.
if oldField == nil {
tempName := field.Name + security.RandomString(5)
toRename[tempName] = field.Name
// add
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
if err != nil {
return err
}
} else {
// add
_, err := txDao.DB().AddColumn(newTableName, field.Name, field.ColDefinition()).Execute()
} else if oldField.Name != field.Name {
tempName := field.Name + security.RandomString(5)
toRename[tempName] = field.Name
// rename
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
if err != nil {
return err
}
}
}
// set the actual columns name
for tempName, actualName := range toRename {
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
if err != nil {
return err
}
}
return nil
})
}
+16 -7
View File
@@ -17,21 +17,27 @@ const MaxExpandDepth = 6
type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]*models.Record, error)
// ExpandRecord expands the relations of a single Record model.
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) error {
//
// Returns a map with the failed expand parameters and their errors.
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc)
}
// ExpandRecords expands the relations of the provided Record models list.
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) error {
//
// Returns a map with the failed expand parameters and their errors.
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
normalized := normalizeExpands(expands)
failed := map[string]error{}
for _, expand := range normalized {
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
return err
failed[expand] = err
}
}
return nil
return failed
}
// notes:
@@ -52,11 +58,14 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
// extract the relation field (if exist)
mainCollection := records[0].Collection()
relField := mainCollection.Schema.GetFieldByName(parts[0])
if relField == nil {
return fmt.Errorf("Couldn't find field %q in collection %q.", parts[0], mainCollection.Name)
if relField == nil || relField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
}
relField.InitOptions()
relFieldOptions, _ := relField.Options.(*schema.RelationOptions)
relFieldOptions, ok := relField.Options.(*schema.RelationOptions)
if !ok {
return fmt.Errorf("Cannot initialize the options of relation field %q.", parts[0])
}
relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
if err != nil {
+47 -39
View File
@@ -19,11 +19,11 @@ func TestExpandRecords(t *testing.T) {
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct {
recordIds []string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectError bool
recordIds []string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
// empty records
{
@@ -33,7 +33,7 @@ func TestExpandRecords(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
false,
0,
},
// empty expand
{
@@ -43,7 +43,7 @@ func TestExpandRecords(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
false,
0,
},
// empty fetchFunc
{
@@ -51,7 +51,7 @@ func TestExpandRecords(t *testing.T) {
[]string{"onerel", "manyrels.onerel.manyrels"},
nil,
0,
true,
2,
},
// fetchFunc with error
{
@@ -61,9 +61,9 @@ func TestExpandRecords(t *testing.T) {
return nil, errors.New("test error")
},
0,
true,
2,
},
// invalid missing first level expand
// missing relation field
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"invalid"},
@@ -71,9 +71,19 @@ func TestExpandRecords(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
true,
1,
},
// invalid missing second level expand
// existing, but non-relation type field
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
1,
},
// invalid/missing second level expand
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"manyrels.invalid"},
@@ -81,7 +91,7 @@ func TestExpandRecords(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
true,
1,
},
// expand normalizations
{
@@ -96,7 +106,7 @@ func TestExpandRecords(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
9,
false,
0,
},
// single expand
{
@@ -111,7 +121,7 @@ func TestExpandRecords(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
2,
false,
0,
},
// maxExpandDepth reached
{
@@ -121,18 +131,17 @@ func TestExpandRecords(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
6,
false,
0,
},
}
for i, s := range scenarios {
ids := list.ToUniqueStringSlice(s.recordIds)
records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
err := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
if len(failed) != s.expectExpandFailures {
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(records)
@@ -140,7 +149,7 @@ func TestExpandRecords(t *testing.T) {
totalExpandProps := strings.Count(encodedStr, "@expand")
if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps)
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
@@ -152,11 +161,11 @@ func TestExpandRecord(t *testing.T) {
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
scenarios := []struct {
recordId string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectError bool
recordId string
expands []string
fetchFunc daos.ExpandFetchFunc
expectExpandProps int
expectExpandFailures int
}{
// empty expand
{
@@ -166,7 +175,7 @@ func TestExpandRecord(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
false,
0,
},
// empty fetchFunc
{
@@ -174,7 +183,7 @@ func TestExpandRecord(t *testing.T) {
[]string{"onerel", "manyrels.onerel.manyrels"},
nil,
0,
true,
2,
},
// fetchFunc with error
{
@@ -184,7 +193,7 @@ func TestExpandRecord(t *testing.T) {
return nil, errors.New("test error")
},
0,
true,
2,
},
// invalid missing first level expand
{
@@ -194,7 +203,7 @@ func TestExpandRecord(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
true,
1,
},
// invalid missing second level expand
{
@@ -204,7 +213,7 @@ func TestExpandRecord(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
true,
1,
},
// expand normalizations
{
@@ -214,7 +223,7 @@ func TestExpandRecord(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
3,
false,
0,
},
// single expand
{
@@ -224,7 +233,7 @@ func TestExpandRecord(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
1,
false,
0,
},
// maxExpandDepth reached
{
@@ -234,17 +243,16 @@ func TestExpandRecord(t *testing.T) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
6,
false,
0,
},
}
for i, s := range scenarios {
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
err := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
if len(failed) != s.expectExpandFailures {
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
}
encoded, _ := json.Marshal(record)
@@ -252,7 +260,7 @@ func TestExpandRecord(t *testing.T) {
totalExpandProps := strings.Count(encodedStr, "@expand")
if s.expectExpandProps != totalExpandProps {
t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps)
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
}
}
}
+36 -5
View File
@@ -6,20 +6,51 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// AdminLogin defines an admin email/pass login form.
// AdminLogin specifies an admin email/pass login form.
type AdminLogin struct {
app core.App
config AdminLoginConfig
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
}
// NewAdminLogin creates new admin login form for the provided app.
// AdminLoginConfig is the [AdminLogin] factory initializer config.
//
// NB! App is a required struct member.
type AdminLoginConfig struct {
App core.App
TxDao *daos.Dao
}
// NewAdminLogin creates a new [AdminLogin] form with initializer
// config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewAdminLoginWithConfig] with explicitly set TxDao.
func NewAdminLogin(app core.App) *AdminLogin {
return &AdminLogin{app: app}
return NewAdminLoginWithConfig(AdminLoginConfig{
App: app,
})
}
// NewAdminLoginWithConfig creates a new [AdminLogin] form
// with the provided config or panics on invalid configuration.
func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin {
form := &AdminLogin{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -37,7 +68,7 @@ func (form *AdminLogin) Submit() (*models.Admin, error) {
return nil, err
}
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
if err != nil {
return nil, err
}
+10
View File
@@ -7,6 +7,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminLoginPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminLogin(nil)
}
func TestAdminLoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+39 -10
View File
@@ -3,24 +3,53 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// AdminPasswordResetConfirm defines an admin password reset confirmation form.
// AdminPasswordResetConfirm specifies an admin password reset confirmation form.
type AdminPasswordResetConfirm struct {
app core.App
config AdminPasswordResetConfirmConfig
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewAdminPasswordResetConfirm creates new admin password reset confirmation form.
// AdminPasswordResetConfirmConfig is the [AdminPasswordResetConfirm] factory initializer config.
//
// NB! App is required struct member.
type AdminPasswordResetConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set TxDao.
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
return &AdminPasswordResetConfirm{
app: app,
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
App: app,
})
}
// NewAdminPasswordResetConfirmWithConfig creates a new [AdminPasswordResetConfirm]
// form with the provided config or panics on invalid configuration.
func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConfig) *AdminPasswordResetConfirm {
form := &AdminPasswordResetConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -38,9 +67,9 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
return nil // nothing to check
}
admin, err := form.app.Dao().FindAdminByToken(
admin, err := form.config.TxDao.FindAdminByToken(
v,
form.app.Settings().AdminPasswordResetToken.Secret,
form.config.App.Settings().AdminPasswordResetToken.Secret,
)
if err != nil || admin == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -56,9 +85,9 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
admin, err := form.app.Dao().FindAdminByToken(
admin, err := form.config.TxDao.FindAdminByToken(
form.Token,
form.app.Settings().AdminPasswordResetToken.Secret,
form.config.App.Settings().AdminPasswordResetToken.Secret,
)
if err != nil {
return nil, err
@@ -68,7 +97,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
if err := form.app.Dao().SaveAdmin(admin); err != nil {
if err := form.config.TxDao.SaveAdmin(admin); err != nil {
return nil, err
}
@@ -8,6 +8,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestAdminPasswordResetPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminPasswordResetConfirm(nil)
}
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+40 -11
View File
@@ -7,24 +7,53 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/types"
)
// AdminPasswordResetRequest defines an admin password reset request form.
// AdminPasswordResetRequest specifies an admin password reset request form.
type AdminPasswordResetRequest struct {
app core.App
resendThreshold float64
config AdminPasswordResetRequestConfig
Email string `form:"email" json:"email"`
}
// NewAdminPasswordResetRequest creates new admin password reset request form.
// AdminPasswordResetRequestConfig is the [AdminPasswordResetRequest] factory initializer config.
//
// NB! App is required struct member.
type AdminPasswordResetRequestConfig struct {
App core.App
TxDao *daos.Dao
ResendThreshold float64 // in seconds
}
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewAdminPasswordResetRequestWithConfig] with explicitly set TxDao.
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
return &AdminPasswordResetRequest{
app: app,
resendThreshold: 120, // 2 min
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
App: app,
ResendThreshold: 120, // 2min
})
}
// NewAdminPasswordResetRequestWithConfig creates a new [AdminPasswordResetRequest]
// form with the provided config or panics on invalid configuration.
func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConfig) *AdminPasswordResetRequest {
form := &AdminPasswordResetRequest{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,23 +77,23 @@ func (form *AdminPasswordResetRequest) Submit() error {
return err
}
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
if err != nil {
return err
}
now := time.Now().UTC()
lastResetSentAt := admin.LastResetSentAt.Time()
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
return errors.New("You have already requested a password reset.")
}
if err := mails.SendAdminPasswordReset(form.app, admin); err != nil {
if err := mails.SendAdminPasswordReset(form.config.App, admin); err != nil {
return err
}
// update last sent timestamp
admin.LastResetSentAt = types.NowDateTime()
return form.app.Dao().SaveAdmin(admin)
return form.config.TxDao.SaveAdmin(admin)
}
@@ -7,6 +7,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminPasswordResetRequestPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminPasswordResetRequest(nil)
}
func TestAdminPasswordResetRequestValidate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+56 -12
View File
@@ -4,32 +4,62 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// AdminUpsert defines an admin upsert (create/update) form.
// AdminUpsert specifies a [models.Admin] upsert (create/update) form.
type AdminUpsert struct {
app core.App
admin *models.Admin
isCreate bool
config AdminUpsertConfig
admin *models.Admin
Id string `form:"id" json:"id"`
Avatar int `form:"avatar" json:"avatar"`
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewAdminUpsert creates new upsert form for the provided admin model
// (pass an empty admin model instance (`&models.Admin{}`) for create).
// AdminUpsertConfig is the [AdminUpsert] factory initializer config.
//
// NB! App is a required struct member.
type AdminUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
// config created from the provided [core.App] and [models.Admin] instances
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
//
// If you want to submit the form as part of another transaction, use
// [NewAdminUpsertWithConfig] with explicitly set TxDao.
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
return NewAdminUpsertWithConfig(AdminUpsertConfig{
App: app,
}, admin)
}
// NewAdminUpsertWithConfig creates a new [AdminUpsert] form
// with the provided config and [models.Admin] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert {
form := &AdminUpsert{
app: app,
admin: admin,
isCreate: !admin.HasId(),
config: config,
admin: admin,
}
if form.config.App == nil || form.admin == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
// load defaults
form.Id = admin.Id
form.Avatar = admin.Avatar
form.Email = admin.Email
@@ -39,6 +69,14 @@ func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminUpsert) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.admin.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.admin.Id)),
),
validation.Field(
&form.Avatar,
validation.Min(0),
@@ -53,7 +91,7 @@ func (form *AdminUpsert) Validate() error {
),
validation.Field(
&form.Password,
validation.When(form.isCreate, validation.Required),
validation.When(form.admin.IsNew(), validation.Required),
validation.Length(10, 100),
),
validation.Field(
@@ -67,7 +105,7 @@ func (form *AdminUpsert) Validate() error {
func (form *AdminUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string)
if form.app.Dao().IsAdminEmailUnique(v, form.admin.Id) {
if form.config.TxDao.IsAdminEmailUnique(v, form.admin.Id) {
return nil
}
@@ -83,6 +121,12 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
// custom insertion id can be set only on create
if form.admin.IsNew() && form.Id != "" {
form.admin.MarkAsNew()
form.admin.SetId(form.Id)
}
form.admin.Avatar = form.Avatar
form.admin.Email = form.Email
@@ -91,6 +135,6 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
}
return runInterceptors(func() error {
return form.app.Dao().SaveAdmin(form.admin)
return form.config.TxDao.SaveAdmin(form.admin)
}, interceptors...)
}
+126
View File
@@ -3,6 +3,7 @@ package forms_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -11,6 +12,29 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminUpsert(nil, nil)
}
func TestAdminUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminUpsert(app, nil)
}
func TestNewAdminUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -347,3 +371,105 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestAdminUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
jsonData string
collection *models.Admin
expectError bool
}{
{
"empty data",
"{}",
&models.Admin{},
false,
},
{
"empty id",
`{"id":""}`,
&models.Admin{},
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
&models.Admin{},
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
&models.Admin{},
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
&models.Admin{},
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
&models.Admin{},
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingAdmin,
true,
},
{
"using the same existing item id",
`{"id":"` + existingAdmin.Id + `"}`,
existingAdmin,
false,
},
{
"skipping the id for existing item",
`{}`,
existingAdmin,
false,
},
}
for i, scenario := range scenarios {
form := forms.NewAdminUpsert(app, scenario.collection)
if form.Email == "" {
form.Email = fmt.Sprintf("test_id_%d@example.com", i)
}
form.Password = "1234567890"
form.PasswordConfirm = form.Password
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindAdminById(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
}
}
}
}
+5
View File
@@ -2,6 +2,11 @@
// validation and applying changes to existing DB models through the app Dao.
package forms
import "regexp"
// base ID value regex pattern
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
// InterceptorNextFunc is a interceptor handler function.
// Usually used in combination with InterceptorFunc.
type InterceptorNextFunc = func() error
+99 -41
View File
@@ -1,11 +1,13 @@
package forms
import (
"fmt"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
@@ -14,12 +16,12 @@ import (
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
// CollectionUpsert defines a collection upsert (create/update) form.
// CollectionUpsert specifies a [models.Collection] upsert (create/update) form.
type CollectionUpsert struct {
app core.App
config CollectionUpsertConfig
collection *models.Collection
isCreate bool
Id string `form:"id" json:"id"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
@@ -30,25 +32,54 @@ type CollectionUpsert struct {
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
}
// NewCollectionUpsert creates new collection upsert form for the provided Collection model
// (pass an empty Collection model instance (`&models.Collection{}`) for create).
// CollectionUpsertConfig is the [CollectionUpsert] factory initializer config.
//
// NB! App is a required struct member.
type CollectionUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
// config created from the provided [core.App] and [models.Collection] instances
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
//
// If you want to submit the form as part of another transaction, use
// [NewCollectionUpsertWithConfig] with explicitly set TxDao.
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
App: app,
}, collection)
}
// NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form
// with the provided config and [models.Collection] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert {
form := &CollectionUpsert{
app: app,
config: config,
collection: collection,
isCreate: !collection.HasId(),
}
if form.config.App == nil || form.collection == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
// load defaults
form.Name = collection.Name
form.System = collection.System
form.ListRule = collection.ListRule
form.ViewRule = collection.ViewRule
form.CreateRule = collection.CreateRule
form.UpdateRule = collection.UpdateRule
form.DeleteRule = collection.DeleteRule
form.Id = form.collection.Id
form.Name = form.collection.Name
form.System = form.collection.System
form.ListRule = form.collection.ListRule
form.ViewRule = form.collection.ViewRule
form.CreateRule = form.collection.CreateRule
form.UpdateRule = form.collection.UpdateRule
form.DeleteRule = form.collection.DeleteRule
clone, _ := collection.Schema.Clone()
clone, _ := form.collection.Schema.Clone()
if clone != nil {
form.Schema = *clone
} else {
@@ -61,6 +92,14 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.collection.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.collection.Id)),
),
validation.Field(
&form.System,
validation.By(form.ensureNoSystemFlagChange),
@@ -78,7 +117,7 @@ func (form *CollectionUpsert) Validate() error {
&form.Schema,
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureNoFieldsNameReuse),
validation.By(form.ensureExistingRelationCollectionId),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
@@ -91,11 +130,11 @@ func (form *CollectionUpsert) Validate() error {
func (form *CollectionUpsert) checkUniqueName(value any) error {
v, _ := value.(string)
if !form.app.Dao().IsCollectionNameUnique(v, form.collection.Id) {
if !form.config.TxDao.IsCollectionNameUnique(v, form.collection.Id) {
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
}
if (form.isCreate || !strings.EqualFold(v, form.collection.Name)) && form.app.Dao().HasTable(v) {
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.TxDao.HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
@@ -105,7 +144,7 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
v, _ := value.(string)
if form.isCreate || !form.collection.System || v == form.collection.Name {
if form.collection.IsNew() || !form.collection.System || v == form.collection.Name {
return nil
}
@@ -115,7 +154,7 @@ func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
v, _ := value.(bool)
if form.isCreate || v == form.collection.System {
if form.collection.IsNew() || v == form.collection.System {
return nil
}
@@ -125,11 +164,38 @@ func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
v, _ := value.(schema.Schema)
for _, field := range v.Fields() {
for i, field := range v.Fields() {
oldField := form.collection.Schema.GetFieldById(field.Id)
if oldField != nil && oldField.Type != field.Type {
return validation.NewError("validation_field_type_change", "Field type cannot be changed.")
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_type_change",
"Field type cannot be changed.",
)}
}
}
return nil
}
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
v, _ := value.(schema.Schema)
for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation {
continue
}
options, _ := field.Options.(*schema.RelationOptions)
if options == nil {
continue
}
if _, err := form.config.TxDao.FindCollectionByNameOrId(options.CollectionId); err != nil {
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
)}
}
}
@@ -154,20 +220,6 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
return nil
}
func (form *CollectionUpsert) ensureNoFieldsNameReuse(value any) error {
v, _ := value.(schema.Schema)
for _, field := range v.Fields() {
oldField := form.collection.Schema.GetFieldByName(field.Name)
if oldField != nil && oldField.Id != field.Id {
return validation.NewError("validation_field_old_field_exist", "Cannot use existing schema field names when renaming fields.")
}
}
return nil
}
func (form *CollectionUpsert) checkRule(value any) error {
v, _ := value.(*string)
@@ -176,7 +228,7 @@ func (form *CollectionUpsert) checkRule(value any) error {
}
dummy := &models.Collection{Schema: form.Schema}
r := resolvers.NewRecordFieldResolver(form.app.Dao(), dummy, nil)
r := resolvers.NewRecordFieldResolver(form.config.TxDao, dummy, nil)
_, err := search.FilterData(*v).BuildExpr(r)
if err != nil {
@@ -197,13 +249,19 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
// system flag can be set only for create
if form.isCreate {
if form.collection.IsNew() {
// system flag can be set only on create
form.collection.System = form.System
// custom insertion id can be set only on create
if form.Id != "" {
form.collection.MarkAsNew()
form.collection.SetId(form.Id)
}
}
// system collections cannot be renamed
if form.isCreate || !form.collection.System {
if form.collection.IsNew() || !form.collection.System {
form.collection.Name = form.Name
}
@@ -215,6 +273,6 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.DeleteRule = form.DeleteRule
return runInterceptors(func() error {
return form.app.Dao().SaveCollection(form.collection)
return form.config.TxDao.SaveCollection(form.collection)
}, interceptors...)
}
+130 -2
View File
@@ -10,9 +10,33 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
func TestCollectionUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionUpsert(nil, nil)
}
func TestCollectionUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionUpsert(app, nil)
}
func TestNewCollectionUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -261,7 +285,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
}`,
[]string{"schema"},
},
// update failure - rename fields to existing field names (aka. reusing field names)
// update success - rename fields to existing field names (aka. reusing field names)
{
"test_new",
`{
@@ -270,7 +294,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
{"id":"b123456","name":"test1","type":"email"}
]
}`,
[]string{"schema"},
[]string{},
},
// update failure - existing name
{
@@ -518,3 +542,107 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestCollectionUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
newCollection := func() *models.Collection {
return &models.Collection{
Name: "c_" + security.RandomString(4),
Schema: existingCollection.Schema,
}
}
scenarios := []struct {
name string
jsonData string
collection *models.Collection
expectError bool
}{
{
"empty data",
"{}",
newCollection(),
false,
},
{
"empty id",
`{"id":""}`,
newCollection(),
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
newCollection(),
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
newCollection(),
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
newCollection(),
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
newCollection(),
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingCollection,
true,
},
{
"using the same existing item id",
`{"id":"` + existingCollection.Id + `"}`,
existingCollection,
false,
},
{
"skipping the id for existing item",
`{}`,
existingCollection,
false,
},
}
for _, scenario := range scenarios {
form := forms.NewCollectionUpsert(app, scenario.collection)
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindCollectionByNameOrId(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
package forms
import (
"encoding/json"
"fmt"
"log"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// CollectionsImport specifies a form model to bulk import
// (create, replace and delete) collections from a user provided list.
type CollectionsImport struct {
config CollectionsImportConfig
Collections []*models.Collection `form:"collections" json:"collections"`
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
}
// CollectionsImportConfig is the [CollectionsImport] factory initializer config.
//
// NB! App is a required struct member.
type CollectionsImportConfig struct {
App core.App
TxDao *daos.Dao
}
// NewCollectionsImport creates a new [CollectionsImport] form with
// initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewCollectionsImportWithConfig] with explicitly set TxDao.
func NewCollectionsImport(app core.App) *CollectionsImport {
return NewCollectionsImportWithConfig(CollectionsImportConfig{
App: app,
})
}
// NewCollectionsImportWithConfig creates a new [CollectionsImport]
// form with the provided config or panics on invalid configuration.
func NewCollectionsImportWithConfig(config CollectionsImportConfig) *CollectionsImport {
form := &CollectionsImport{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionsImport) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Collections, validation.Required),
)
}
// Submit applies the import, aka.:
// - imports the form collections (create or replace)
// - sync the collection changes with their related records table
// - ensures the integrity of the imported structure (aka. run validations for each collection)
// - if [form.DeleteMissing] is set, deletes all local collections that are not found in the imports list
//
// All operations are wrapped in a single transaction that are
// rollbacked on the first encountered error.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(func() error {
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
importErr := txDao.ImportCollections(
form.Collections,
form.DeleteMissing,
form.beforeRecordsSync,
)
if importErr == nil {
return nil
}
// validation failure
if err, ok := importErr.(validation.Errors); ok {
return err
}
// generic/db failure
if form.config.App.IsDebug() {
log.Println("Internal import failure:", importErr)
}
return validation.Errors{"collections": validation.NewError(
"collections_import_failure",
"Failed to import the collections configuration.",
)}
})
}, interceptors...)
}
func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
// refresh the actual persisted collections list
refreshedCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&refreshedCollections); err != nil {
return err
}
// trigger the validator for each existing collection to
// ensure that the app is not left in a broken state
for _, collection := range refreshedCollections {
upsertModel := mappedOld[collection.GetId()]
if upsertModel == nil {
upsertModel = collection
}
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
App: form.config.App,
TxDao: txDao,
}, upsertModel)
// load form fields with the refreshed collection state
upsertForm.Id = collection.Id
upsertForm.Name = collection.Name
upsertForm.System = collection.System
upsertForm.ListRule = collection.ListRule
upsertForm.ViewRule = collection.ViewRule
upsertForm.CreateRule = collection.CreateRule
upsertForm.UpdateRule = collection.UpdateRule
upsertForm.DeleteRule = collection.DeleteRule
upsertForm.Schema = collection.Schema
if err := upsertForm.Validate(); err != nil {
// serialize the validation error(s)
serializedErr, _ := json.MarshalIndent(err, "", " ")
return validation.Errors{"collections": validation.NewError(
"collections_import_validate_failure",
fmt.Sprintf("Data validations failed for collection %q (%s):\n%s", collection.Name, collection.Id, serializedErr),
)}
}
}
return nil
}
+419
View File
@@ -0,0 +1,419 @@
package forms_test
import (
"encoding/json"
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestCollectionsImportPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionsImport(nil)
}
func TestCollectionsImportValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewCollectionsImport(app)
scenarios := []struct {
collections []*models.Collection
expectError bool
}{
{nil, true},
{[]*models.Collection{}, true},
{[]*models.Collection{{}}, false},
}
for i, s := range scenarios {
form.Collections = s.collections
err := form.Validate()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
func TestCollectionsImportSubmit(t *testing.T) {
scenarios := []struct {
name string
jsonData string
expectError bool
expectCollectionsCount int
expectEvents map[string]int
}{
{
name: "empty collections",
jsonData: `{
"deleteMissing": true,
"collections": []
}`,
expectError: true,
expectCollectionsCount: 5,
expectEvents: nil,
},
{
name: "one of the collections has invalid data",
jsonData: `{
"collections": [
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import 2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 5,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
},
{
name: "all imported collections has valid data",
jsonData: `{
"collections": [
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
},
},
{
name: "new collection with existing name",
jsonData: `{
"collections": [
{
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 5,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
},
{
name: "delete system + modified + new collection",
jsonData: `{
"deleteMissing": true,
"collections": [
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "modified + new collection",
jsonData: `{
"collections": [
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
},
},
{
name: "delete non-system + modified + new collection",
jsonData: `{
"deleteMissing": true,
"collections": [
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
}
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"id": "test_deleted_collection_name_reuse",
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 3,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnModelBeforeDelete": 3,
"OnModelAfterDelete": 3,
},
},
}
for _, s := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewCollectionsImport(testApp)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != s.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
}
// check events
if len(testApp.EventCalls) > len(s.expectEvents) {
t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
}
for event, expectedCalls := range s.expectEvents {
actualCalls := testApp.EventCalls[event]
if actualCalls != expectedCalls {
t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
}
}
}
}
func TestCollectionsImportSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
form := forms.NewCollectionsImport(app)
form.Collections = collections
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1Called = true
return next()
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
)
// RealtimeSubscribe defines a RealtimeSubscribe request form.
// RealtimeSubscribe specifies a RealtimeSubscribe request form.
type RealtimeSubscribe struct {
ClientId string `form:"clientId" json:"clientId"`
Subscriptions []string `form:"subscriptions" json:"subscriptions"`
+94 -14
View File
@@ -4,10 +4,12 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
@@ -19,29 +21,59 @@ import (
"github.com/spf13/cast"
)
// RecordUpsert defines a Record upsert form.
// RecordUpsert specifies a [models.Record] upsert (create/update) form.
type RecordUpsert struct {
app core.App
config RecordUpsertConfig
record *models.Record
isCreate bool
filesToDelete []string // names list
filesToUpload []*rest.UploadedFile
Id string `form:"id" json:"id"`
Data map[string]any `json:"data"`
}
// NewRecordUpsert creates a new Record upsert form.
// (pass a new Record model instance (`models.NewRecord(...)`) for create).
// RecordUpsertConfig is the [RecordUpsert] factory initializer config.
//
// NB! App is required struct member.
type RecordUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
// config created from the provided [core.App] and [models.Record] instances
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
//
// If you want to submit the form as part of another transaction, use
// [NewRecordUpsertWithConfig] with explicitly set TxDao.
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
return NewRecordUpsertWithConfig(RecordUpsertConfig{
App: app,
}, record)
}
// NewRecordUpsertWithConfig creates a new [RecordUpsert] form
// with the provided config and [models.Record] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record) *RecordUpsert {
form := &RecordUpsert{
app: app,
config: config,
record: record,
isCreate: !record.HasId(),
filesToDelete: []string{},
filesToUpload: []*rest.UploadedFile{},
}
if form.config.App == nil || form.record == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
form.Id = record.Id
form.Data = map[string]any{}
for _, field := range record.Collection().Schema.Fields() {
form.Data[field.Name] = record.GetDataValue(field.Name)
@@ -135,6 +167,10 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
return err
}
if id, ok := requestData["id"]; ok {
form.Id = cast.ToString(id)
}
// extend base data with the extracted one
extendedData := form.record.Data()
rawData, err := json.Marshal(requestData)
@@ -167,7 +203,7 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
if len(oldNames) > 0 {
form.filesToDelete = append(form.filesToDelete, oldNames...)
}
form.Data[key] = nil
form.Data[key] = ""
}
} else if options.MaxSelect > 1 {
// search for individual file index to delete (eg. "file.0")
@@ -201,6 +237,10 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
// check if there are any new uploaded form files
files, err := rest.FindUploadedFiles(r, key)
if err != nil {
if form.config.App.IsDebug() {
log.Printf("%q uploaded file error: %v\n", key, err)
}
continue // skip invalid or missing file(s)
}
@@ -229,8 +269,24 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordUpsert) Validate() error {
// base form fields validator
baseFieldsErrors := validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.record.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.record.Id)),
),
)
if baseFieldsErrors != nil {
return baseFieldsErrors
}
// record data validator
dataValidator := validators.NewRecordDataValidator(
form.app.Dao(),
form.config.TxDao,
form.record,
form.filesToUpload,
)
@@ -247,17 +303,26 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
return err
}
isNew := form.record.IsNew()
// custom insertion id can be set only on create
if isNew && form.Id != "" {
form.record.MarkAsNew()
form.record.SetId(form.Id)
}
// bulk load form data
if err := form.record.Load(form.Data); err != nil {
return err
}
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
tx, ok := txDao.DB().(*dbx.Tx)
if !ok {
return errors.New("failed to get transaction db")
}
defer tx.Rollback()
txDao.BeforeCreateFunc = nil
txDao.AfterCreateFunc = nil
txDao.BeforeUpdateFunc = nil
@@ -267,7 +332,16 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
return err
}
return callback(txDao)
// restore record isNew state
if isNew {
form.record.MarkAsNew()
}
if callback != nil {
return callback(txDao)
}
return nil
})
}
@@ -280,13 +354,19 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
// custom insertion id can be set only on create
if form.record.IsNew() && form.Id != "" {
form.record.MarkAsNew()
form.record.SetId(form.Id)
}
// bulk load form data
if err := form.record.Load(form.Data); err != nil {
return err
}
return runInterceptors(func() error {
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
// persist record model
if err := txDao.SaveRecord(form.record); err != nil {
return err
@@ -317,7 +397,7 @@ func (form *RecordUpsert) processFilesToUpload() error {
return errors.New("The record is not persisted yet.")
}
fs, err := form.app.NewFilesystem()
fs, err := form.config.App.NewFilesystem()
if err != nil {
return err
}
@@ -353,7 +433,7 @@ func (form *RecordUpsert) processFilesToDelete() error {
return errors.New("The record is not persisted yet.")
}
fs, err := form.app.NewFilesystem()
fs, err := form.config.App.NewFilesystem()
if err != nil {
return err
}
+146 -5
View File
@@ -20,6 +20,29 @@ import (
"github.com/pocketbase/pocketbase/tools/list"
)
func TestRecordUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewRecordUpsert(nil, nil)
}
func TestRecordUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewRecordUpsert(app, nil)
}
func TestNewRecordUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -32,7 +55,7 @@ func TestNewRecordUpsert(t *testing.T) {
val := form.Data["title"]
if val != "test_value" {
t.Errorf("Expected record data to be load, got %v", form.Data)
t.Errorf("Expected record data to be loaded, got %v", form.Data)
}
}
@@ -68,6 +91,7 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
}
testData := map[string]any{
"id": "test_id",
"title": "test123",
"unknown": "test456",
// file fields unset/delete
@@ -86,6 +110,10 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
t.Fatal(loadErr)
}
if form.Id != "test_id" {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
if v, ok := form.Data["title"]; !ok || v != "test123" {
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
}
@@ -98,8 +126,8 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
if !ok {
t.Fatal("Expect onefile field to be set")
}
if onefile != nil {
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
if onefile != "" {
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
}
manyfiles, ok := form.Data["manyfiles"]
@@ -134,6 +162,7 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
}
formData, mp, err := tests.MockMultipartData(map[string]string{
"id": "test_id",
"title": "test123",
"unknown": "test456",
// file fields unset/delete
@@ -154,6 +183,10 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
t.Fatal(loadErr)
}
if form.Id != "test_id" {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
if v, ok := form.Data["title"]; !ok || v != "test123" {
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
}
@@ -166,8 +199,8 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
if !ok {
t.Fatal("Expect onefile field to be set")
}
if onefile != nil {
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
if onefile != "" {
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
}
manyfiles, ok := form.Data["manyfiles"]
@@ -202,6 +235,7 @@ func TestRecordUpsertValidateFailure(t *testing.T) {
// try with invalid test data to check whether the RecordDataValidator is triggered
formData, mp, err := tests.MockMultipartData(map[string]string{
"id": "",
"unknown": "test456", // should be ignored
"title": "a",
"onerel": "00000000-84ab-4057-a592-4604a731f78f",
@@ -247,6 +281,7 @@ func TestRecordUpsertValidateSuccess(t *testing.T) {
}
formData, mp, err := tests.MockMultipartData(map[string]string{
"id": record.Id,
"unknown": "test456", // should be ignored
"title": "abc",
"onerel": "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
@@ -576,3 +611,109 @@ func hasRecordFile(app core.App, record *models.Record, filename string) bool {
return exists
}
func TestRecordUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo3")
existingRecord, err := app.Dao().FindFirstRecordByData(collection, "id", "2c542824-9de1-42fe-8924-e57c86267760")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
data map[string]string
record *models.Record
expectError bool
}{
{
"empty data",
map[string]string{},
models.NewRecord(collection),
false,
},
{
"empty id",
map[string]string{"id": ""},
models.NewRecord(collection),
false,
},
{
"id < 15 chars",
map[string]string{"id": "a23"},
models.NewRecord(collection),
true,
},
{
"id > 15 chars",
map[string]string{"id": "a234567890123456"},
models.NewRecord(collection),
true,
},
{
"id = 15 chars (invalid chars)",
map[string]string{"id": "a@3456789012345"},
models.NewRecord(collection),
true,
},
{
"id = 15 chars (valid chars)",
map[string]string{"id": "a23456789012345"},
models.NewRecord(collection),
false,
},
{
"changing the id of an existing record",
map[string]string{"id": "b23456789012345"},
existingRecord,
true,
},
{
"using the same existing record id",
map[string]string{"id": existingRecord.Id},
existingRecord,
false,
},
{
"skipping the id for existing record",
map[string]string{},
existingRecord,
false,
},
}
for _, scenario := range scenarios {
formData, mp, err := tests.MockMultipartData(scenario.data)
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordUpsert(app, scenario.record)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
form.LoadData(req)
dryErr := form.DrySubmit(nil)
hasDryErr := dryErr != nil
submitErr := form.Submit()
hasSubmitErr := submitErr != nil
if hasDryErr != hasSubmitErr {
t.Errorf("[%s] Expected hasDryErr and hasSubmitErr to have the same value, got %v vs %v", scenario.name, hasDryErr, hasSubmitErr)
}
if hasSubmitErr != scenario.expectError {
t.Errorf("[%s] Expected hasSubmitErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasSubmitErr, submitErr)
}
if id, ok := scenario.data["id"]; ok && id != "" && !hasSubmitErr {
_, err := app.Dao().FindRecordById(collection, id, nil)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, id, err)
}
}
}
}
+43 -9
View File
@@ -5,22 +5,56 @@ import (
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// SettingsUpsert defines app settings upsert form.
// SettingsUpsert specifies a [core.Settings] upsert (create/update) form.
type SettingsUpsert struct {
*core.Settings
app core.App
config SettingsUpsertConfig
}
// NewSettingsUpsert creates new settings upsert form from the provided app.
// SettingsUpsertConfig is the [SettingsUpsert] factory initializer config.
//
// NB! App is required struct member.
type SettingsUpsertConfig struct {
App core.App
TxDao *daos.Dao
TxLogsDao *daos.Dao
}
// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
// config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewSettingsUpsertWithConfig] with explicitly set TxDao.
func NewSettingsUpsert(app core.App) *SettingsUpsert {
form := &SettingsUpsert{app: app}
return NewSettingsUpsertWithConfig(SettingsUpsertConfig{
App: app,
})
}
// NewSettingsUpsertWithConfig creates a new [SettingsUpsert] form
// with the provided config or panics on invalid configuration.
func NewSettingsUpsertWithConfig(config SettingsUpsertConfig) *SettingsUpsert {
form := &SettingsUpsert{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
if form.config.TxLogsDao == nil {
form.config.TxLogsDao = form.config.App.LogsDao()
}
// load the application settings into the form
form.Settings, _ = app.Settings().Clone()
form.Settings, _ = config.App.Settings().Clone()
return form
}
@@ -41,10 +75,10 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
encryptionKey := os.Getenv(form.app.EncryptionEnv())
encryptionKey := os.Getenv(form.config.App.EncryptionEnv())
return runInterceptors(func() error {
saveErr := form.app.Dao().SaveParam(
saveErr := form.config.TxDao.SaveParam(
models.ParamAppSettings,
form.Settings,
encryptionKey,
@@ -54,11 +88,11 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
}
// explicitly trigger old logs deletion
form.app.LogsDao().DeleteOldRequests(
form.config.TxLogsDao.DeleteOldRequests(
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
)
// merge the application settings with the form ones
return form.app.Settings().Merge(form.Settings)
return form.config.App.Settings().Merge(form.Settings)
}, interceptors...)
}
+10
View File
@@ -12,6 +12,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestSettingsUpsertPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewSettingsUpsert(nil)
}
func TestNewSettingsUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+40 -10
View File
@@ -3,23 +3,53 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security"
)
// UserEmailChangeConfirm defines a user email change confirmation form.
// UserEmailChangeConfirm specifies a user email change confirmation form.
type UserEmailChangeConfirm struct {
app core.App
config UserEmailChangeConfirmConfig
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
}
// NewUserEmailChangeConfirm creates new user email change confirmation form.
// UserEmailChangeConfirmConfig is the [UserEmailChangeConfirm] factory initializer config.
//
// NB! App is required struct member.
type UserEmailChangeConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserEmailChangeConfirm creates a new [UserEmailChangeConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// This factory method is used primarily for convenience (and backward compatibility).
// If you want to submit the form as part of another transaction, use
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm {
return &UserEmailChangeConfirm{
app: app,
return NewUserEmailChangeConfirmWithConfig(UserEmailChangeConfirmConfig{
App: app,
})
}
// NewUserEmailChangeConfirmWithConfig creates a new [UserEmailChangeConfirm]
// form with the provided config or panics on invalid configuration.
func NewUserEmailChangeConfirmWithConfig(config UserEmailChangeConfirmConfig) *UserEmailChangeConfirm {
form := &UserEmailChangeConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -73,14 +103,14 @@ func (form *UserEmailChangeConfirm) parseToken(token string) (*models.User, stri
}
// ensure that there aren't other users with the new email
if !form.app.Dao().IsUserEmailUnique(newEmail, "") {
if !form.config.TxDao.IsUserEmailUnique(newEmail, "") {
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
}
// verify that the token is not expired and its signiture is valid
user, err := form.app.Dao().FindUserByToken(
// verify that the token is not expired and its signature is valid
user, err := form.config.TxDao.FindUserByToken(
token,
form.app.Settings().UserEmailChangeToken.Secret,
form.config.App.Settings().UserEmailChangeToken.Secret,
)
if err != nil || user == nil {
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -105,7 +135,7 @@ func (form *UserEmailChangeConfirm) Submit() (*models.User, error) {
user.Verified = true
user.RefreshTokenKey() // invalidate old tokens
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, err
}
+10
View File
@@ -10,6 +10,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestUserEmailChangeConfirmPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailChangeConfirm(nil)
}
func TestUserEmailChangeConfirmValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+39 -8
View File
@@ -4,24 +4,55 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
)
// UserEmailChangeRequest defines a user email change request form.
type UserEmailChangeRequest struct {
app core.App
user *models.User
config UserEmailChangeRequestConfig
user *models.User
NewEmail string `form:"newEmail" json:"newEmail"`
}
// NewUserEmailChangeRequest creates a new user email change request form.
// UserEmailChangeRequestConfig is the [UserEmailChangeRequest] factory initializer config.
//
// NB! App is required struct member.
type UserEmailChangeRequestConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserEmailChangeRequest creates a new [UserEmailChangeRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest {
return &UserEmailChangeRequest{
app: app,
user: user,
return NewUserEmailChangeRequestWithConfig(UserEmailChangeRequestConfig{
App: app,
}, user)
}
// NewUserEmailChangeRequestWithConfig creates a new [UserEmailChangeRequest]
// form with the provided config or panics on invalid configuration.
func NewUserEmailChangeRequestWithConfig(config UserEmailChangeRequestConfig, user *models.User) *UserEmailChangeRequest {
form := &UserEmailChangeRequest{
config: config,
user: user,
}
if form.config.App == nil || form.user == nil {
panic("Invalid initializer config or nil user model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -40,7 +71,7 @@ func (form *UserEmailChangeRequest) Validate() error {
func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error {
v, _ := value.(string)
if !form.app.Dao().IsUserEmailUnique(v, "") {
if !form.config.TxDao.IsUserEmailUnique(v, "") {
return validation.NewError("validation_user_email_exists", "User email already exists.")
}
@@ -53,5 +84,5 @@ func (form *UserEmailChangeRequest) Submit() error {
return err
}
return mails.SendUserChangeEmail(form.app, form.user, form.NewEmail)
return mails.SendUserChangeEmail(form.config.App, form.user, form.NewEmail)
}
+23
View File
@@ -9,6 +9,29 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserEmailChangeRequestPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailChangeRequest(nil, nil)
}
func TestUserEmailChangeRequestPanic2(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailChangeRequest(testApp, nil)
}
func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+34 -6
View File
@@ -4,21 +4,49 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// UserEmailLogin defines a user email/pass login form.
// UserEmailLogin specifies a user email/pass login form.
type UserEmailLogin struct {
app core.App
config UserEmailLoginConfig
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
}
// NewUserEmailLogin creates a new user email/pass login form.
// UserEmailLoginConfig is the [UserEmailLogin] factory initializer config.
//
// NB! App is required struct member.
type UserEmailLoginConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserEmailLogin creates a new [UserEmailLogin] form with
// initializer config created from the provided [core.App] instance.
//
// This factory method is used primarily for convenience (and backward compatibility).
// If you want to submit the form as part of another transaction, use
// [NewUserEmailLoginWithConfig] with explicitly set TxDao.
func NewUserEmailLogin(app core.App) *UserEmailLogin {
form := &UserEmailLogin{
app: app,
return NewUserEmailLoginWithConfig(UserEmailLoginConfig{
App: app,
})
}
// NewUserEmailLoginWithConfig creates a new [UserEmailLogin]
// form with the provided config or panics on invalid configuration.
func NewUserEmailLoginWithConfig(config UserEmailLoginConfig) *UserEmailLogin {
form := &UserEmailLogin{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
@@ -39,7 +67,7 @@ func (form *UserEmailLogin) Submit() (*models.User, error) {
return nil, err
}
user, err := form.app.Dao().FindUserByEmail(form.Email)
user, err := form.config.TxDao.FindUserByEmail(form.Email)
if err != nil {
return nil, err
}
+10
View File
@@ -9,6 +9,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserEmailLoginPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailLogin(nil)
}
func TestUserEmailLoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+45 -11
View File
@@ -7,15 +7,16 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// UserOauth2Login defines a user Oauth2 login form.
// UserOauth2Login specifies a user Oauth2 login form.
type UserOauth2Login struct {
app core.App
config UserOauth2LoginConfig
// The name of the OAuth2 client provider (eg. "google")
Provider string `form:"provider" json:"provider"`
@@ -30,9 +31,39 @@ type UserOauth2Login struct {
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
}
// NewUserOauth2Login creates a new user Oauth2 login form.
// UserOauth2LoginConfig is the [UserOauth2Login] factory initializer config.
//
// NB! App is required struct member.
type UserOauth2LoginConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserOauth2Login creates a new [UserOauth2Login] form with
// initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserOauth2LoginWithConfig] with explicitly set TxDao.
func NewUserOauth2Login(app core.App) *UserOauth2Login {
return &UserOauth2Login{app: app}
return NewUserOauth2LoginWithConfig(UserOauth2LoginConfig{
App: app,
})
}
// NewUserOauth2LoginWithConfig creates a new [UserOauth2Login]
// form with the provided config or panics on invalid configuration.
func NewUserOauth2LoginWithConfig(config UserOauth2LoginConfig) *UserOauth2Login {
form := &UserOauth2Login{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,7 +79,7 @@ func (form *UserOauth2Login) Validate() error {
func (form *UserOauth2Login) checkProviderName(value any) error {
name, _ := value.(string)
config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
config, ok := form.config.App.Settings().NamedAuthProviderConfigs()[name]
if !ok || !config.Enabled {
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
}
@@ -68,7 +99,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
return nil, nil, err
}
config := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
config := form.config.App.Settings().NamedAuthProviderConfigs()[form.Provider]
config.SetupProvider(provider)
provider.SetRedirectUrl(form.RedirectUrl)
@@ -89,12 +120,12 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
}
// login/register the auth user
user, _ := form.app.Dao().FindUserByEmail(authData.Email)
user, _ := form.config.TxDao.FindUserByEmail(authData.Email)
if user != nil {
// update the existing user's verified state
if !user.Verified {
user.Verified = true
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, authData, err
}
}
@@ -108,7 +139,10 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
// create new user
user = &models.User{Verified: true}
upsertForm := NewUserUpsert(form.app, user)
upsertForm := NewUserUpsertWithConfig(UserUpsertConfig{
App: form.config.App,
TxDao: form.config.TxDao,
}, user)
upsertForm.Email = authData.Email
upsertForm.Password = security.RandomString(30)
upsertForm.PasswordConfirm = upsertForm.Password
@@ -118,7 +152,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
AuthData: authData,
}
if err := form.app.OnUserBeforeOauth2Register().Trigger(event); err != nil {
if err := form.config.App.OnUserBeforeOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
@@ -126,7 +160,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
return nil, authData, err
}
if err := form.app.OnUserAfterOauth2Register().Trigger(event); err != nil {
if err := form.config.App.OnUserAfterOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
+10
View File
@@ -9,6 +9,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserOauth2LoginPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserOauth2Login(nil)
}
func TestUserOauth2LoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+41 -11
View File
@@ -3,29 +3,59 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// UserPasswordResetConfirm defines a user password reset confirmation form.
// UserPasswordResetConfirm specifies a user password reset confirmation form.
type UserPasswordResetConfirm struct {
app core.App
config UserPasswordResetConfirmConfig
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewUserPasswordResetConfirm creates new user password reset confirmation form.
// UserPasswordResetConfirmConfig is the [UserPasswordResetConfirm]
// factory initializer config.
//
// NB! App is required struct member.
type UserPasswordResetConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserPasswordResetConfirm creates a new [UserPasswordResetConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserPasswordResetConfirmWithConfig] with explicitly set TxDao.
func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm {
return &UserPasswordResetConfirm{
app: app,
return NewUserPasswordResetConfirmWithConfig(UserPasswordResetConfirmConfig{
App: app,
})
}
// NewUserPasswordResetConfirmWithConfig creates a new [UserPasswordResetConfirm]
// form with the provided config or panics on invalid configuration.
func NewUserPasswordResetConfirmWithConfig(config UserPasswordResetConfirmConfig) *UserPasswordResetConfirm {
form := &UserPasswordResetConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *UserPasswordResetConfirm) Validate() error {
minPasswordLength := form.app.Settings().EmailAuth.MinPasswordLength
minPasswordLength := form.config.App.Settings().EmailAuth.MinPasswordLength
return validation.ValidateStruct(form,
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
@@ -40,9 +70,9 @@ func (form *UserPasswordResetConfirm) checkToken(value any) error {
return nil // nothing to check
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
v,
form.app.Settings().UserPasswordResetToken.Secret,
form.config.App.Settings().UserPasswordResetToken.Secret,
)
if err != nil || user == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -58,9 +88,9 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
return nil, err
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
form.Token,
form.app.Settings().UserPasswordResetToken.Secret,
form.config.App.Settings().UserPasswordResetToken.Secret,
)
if err != nil {
return nil, err
@@ -70,7 +100,7 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
return nil, err
}
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, err
}
+10
View File
@@ -10,6 +10,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestUserPasswordResetConfirmPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserPasswordResetConfirm(nil)
}
func TestUserPasswordResetConfirmValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+41 -11
View File
@@ -7,24 +7,54 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/types"
)
// UserPasswordResetRequest defines a user password reset request form.
// UserPasswordResetRequest specifies a user password reset request form.
type UserPasswordResetRequest struct {
app core.App
resendThreshold float64
config UserPasswordResetRequestConfig
Email string `form:"email" json:"email"`
}
// NewUserPasswordResetRequest creates new user password reset request form.
// UserPasswordResetRequestConfig is the [UserPasswordResetRequest]
// factory initializer config.
//
// NB! App is required struct member.
type UserPasswordResetRequestConfig struct {
App core.App
TxDao *daos.Dao
ResendThreshold float64 // in seconds
}
// NewUserPasswordResetRequest creates a new [UserPasswordResetRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserPasswordResetRequestWithConfig] with explicitly set TxDao.
func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest {
return &UserPasswordResetRequest{
app: app,
resendThreshold: 120, // 2 min
return NewUserPasswordResetRequestWithConfig(UserPasswordResetRequestConfig{
App: app,
ResendThreshold: 120, // 2 min
})
}
// NewUserPasswordResetRequestWithConfig creates a new [UserPasswordResetRequest]
// form with the provided config or panics on invalid configuration.
func NewUserPasswordResetRequestWithConfig(config UserPasswordResetRequestConfig) *UserPasswordResetRequest {
form := &UserPasswordResetRequest{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,23 +78,23 @@ func (form *UserPasswordResetRequest) Submit() error {
return err
}
user, err := form.app.Dao().FindUserByEmail(form.Email)
user, err := form.config.TxDao.FindUserByEmail(form.Email)
if err != nil {
return err
}
now := time.Now().UTC()
lastResetSentAt := user.LastResetSentAt.Time()
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
return errors.New("You've already requested a password reset.")
}
if err := mails.SendUserPasswordReset(form.app, user); err != nil {
if err := mails.SendUserPasswordReset(form.config.App, user); err != nil {
return err
}
// update last sent timestamp
user.LastResetSentAt = types.NowDateTime()
return form.app.Dao().SaveUser(user)
return form.config.TxDao.SaveUser(user)
}
+10
View File
@@ -11,6 +11,16 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestUserPasswordResetRequestPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserPasswordResetRequest(nil)
}
func TestUserPasswordResetRequestValidate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+61 -19
View File
@@ -6,33 +6,63 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
// UserUpsert defines a user upsert (create/update) form.
// UserUpsert specifies a [models.User] upsert (create/update) form.
type UserUpsert struct {
app core.App
user *models.User
isCreate bool
config UserUpsertConfig
user *models.User
Id string `form:"id" json:"id"`
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewUserUpsert creates new upsert form for the provided user model
// (pass an empty user model instance (`&models.User{}`) for create).
// UserUpsertConfig is the [UserUpsert] factory initializer config.
//
// NB! App is required struct member.
type UserUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserUpsert creates a new [UserUpsert] form with initializer
// config created from the provided [core.App] instance
// (for create you could pass a pointer to an empty User - `&models.User{}`).
//
// If you want to submit the form as part of another transaction, use
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
return NewUserUpsertWithConfig(UserUpsertConfig{
App: app,
}, user)
}
// NewUserUpsertWithConfig creates a new [UserUpsert] form with the provided
// config and [models.User] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty User - `&models.User{}`).
func NewUserUpsertWithConfig(config UserUpsertConfig, user *models.User) *UserUpsert {
form := &UserUpsert{
app: app,
user: user,
isCreate: !user.HasId(),
config: config,
user: user,
}
if form.config.App == nil || form.user == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
// load defaults
form.Id = user.Id
form.Email = user.Email
return form
@@ -40,9 +70,15 @@ func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *UserUpsert) Validate() error {
config := form.app.Settings()
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.user.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.user.Id)),
),
validation.Field(
&form.Email,
validation.Required,
@@ -53,12 +89,12 @@ func (form *UserUpsert) Validate() error {
),
validation.Field(
&form.Password,
validation.When(form.isCreate, validation.Required),
validation.Length(config.EmailAuth.MinPasswordLength, 100),
validation.When(form.user.IsNew(), validation.Required),
validation.Length(form.config.App.Settings().EmailAuth.MinPasswordLength, 100),
),
validation.Field(
&form.PasswordConfirm,
validation.When(form.isCreate || form.Password != "", validation.Required),
validation.When(form.user.IsNew() || form.Password != "", validation.Required),
validation.By(validators.Compare(form.Password)),
),
)
@@ -67,7 +103,7 @@ func (form *UserUpsert) Validate() error {
func (form *UserUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string)
if v == "" || form.app.Dao().IsUserEmailUnique(v, form.user.Id) {
if v == "" || form.config.TxDao.IsUserEmailUnique(v, form.user.Id) {
return nil
}
@@ -81,8 +117,8 @@ func (form *UserUpsert) checkEmailDomain(value any) error {
}
domain := val[strings.LastIndex(val, "@")+1:]
only := form.app.Settings().EmailAuth.OnlyDomains
except := form.app.Settings().EmailAuth.ExceptDomains
only := form.config.App.Settings().EmailAuth.OnlyDomains
except := form.config.App.Settings().EmailAuth.ExceptDomains
// only domains check
if len(only) > 0 && !list.ExistInSlice(domain, only) {
@@ -110,7 +146,13 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
form.user.SetPassword(form.Password)
}
if !form.isCreate && form.Email != form.user.Email {
// custom insertion id can be set only on create
if form.user.IsNew() && form.Id != "" {
form.user.MarkAsNew()
form.user.SetId(form.Id)
}
if !form.user.IsNew() && form.Email != form.user.Email {
form.user.Verified = false
form.user.LastVerificationSentAt = types.DateTime{} // reset
}
@@ -118,6 +160,6 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
form.user.Email = form.Email
return runInterceptors(func() error {
return form.app.Dao().SaveUser(form.user)
return form.config.TxDao.SaveUser(form.user)
}, interceptors...)
}
+126
View File
@@ -3,6 +3,7 @@ package forms_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -11,6 +12,29 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserUpsert(nil, nil)
}
func TestUserUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserUpsert(app, nil)
}
func TestNewUserUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -304,3 +328,105 @@ func TestUserUpsertSubmitInterceptors(t *testing.T) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestUserUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingUser, err := app.Dao().FindUserByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
jsonData string
collection *models.User
expectError bool
}{
{
"empty data",
"{}",
&models.User{},
false,
},
{
"empty id",
`{"id":""}`,
&models.User{},
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
&models.User{},
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
&models.User{},
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
&models.User{},
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
&models.User{},
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingUser,
true,
},
{
"using the same existing item id",
`{"id":"` + existingUser.Id + `"}`,
existingUser,
false,
},
{
"skipping the id for existing item",
`{}`,
existingUser,
false,
},
}
for i, scenario := range scenarios {
form := forms.NewUserUpsert(app, scenario.collection)
if form.Email == "" {
form.Email = fmt.Sprintf("test_id_%d@example.com", i)
}
form.Password = "1234567890"
form.PasswordConfirm = form.Password
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindUserById(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
}
}
}
}
+40 -10
View File
@@ -3,21 +3,51 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// UserVerificationConfirm defines a user email confirmation form.
// UserVerificationConfirm specifies a user email verification confirmation form.
type UserVerificationConfirm struct {
app core.App
config UserVerificationConfirmConfig
Token string `form:"token" json:"token"`
}
// NewUserVerificationConfirm creates a new user email confirmation form.
// UserVerificationConfirmConfig is the [UserVerificationConfirm]
// factory initializer config.
//
// NB! App is required struct member.
type UserVerificationConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserVerificationConfirm creates a new [UserVerificationConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserVerificationConfirmWithConfig] with explicitly set TxDao.
func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm {
return &UserVerificationConfirm{
app: app,
return NewUserVerificationConfirmWithConfig(UserVerificationConfirmConfig{
App: app,
})
}
// NewUserVerificationConfirmWithConfig creates a new [UserVerificationConfirmConfig]
// form with the provided config or panics on invalid configuration.
func NewUserVerificationConfirmWithConfig(config UserVerificationConfirmConfig) *UserVerificationConfirm {
form := &UserVerificationConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -33,9 +63,9 @@ func (form *UserVerificationConfirm) checkToken(value any) error {
return nil // nothing to check
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
v,
form.app.Settings().UserVerificationToken.Secret,
form.config.App.Settings().UserVerificationToken.Secret,
)
if err != nil || user == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -51,9 +81,9 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) {
return nil, err
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
form.Token,
form.app.Settings().UserVerificationToken.Secret,
form.config.App.Settings().UserVerificationToken.Secret,
)
if err != nil {
return nil, err
@@ -65,7 +95,7 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) {
user.Verified = true
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, err
}
+10
View File
@@ -10,6 +10,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestUserVerificationConfirmPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserVerificationConfirm(nil)
}
func TestUserVerificationConfirmValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+40 -10
View File
@@ -7,24 +7,54 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/types"
)
// UserVerificationRequest defines a user email verification request form.
type UserVerificationRequest struct {
app core.App
resendThreshold float64
config UserVerificationRequestConfig
Email string `form:"email" json:"email"`
}
// NewUserVerificationRequest creates a new user email verification request form.
// UserVerificationRequestConfig is the [UserVerificationRequest]
// factory initializer config.
//
// NB! App is required struct member.
type UserVerificationRequestConfig struct {
App core.App
TxDao *daos.Dao
ResendThreshold float64 // in seconds
}
// NewUserVerificationRequest creates a new [UserVerificationRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserVerificationRequestWithConfig] with explicitly set TxDao.
func NewUserVerificationRequest(app core.App) *UserVerificationRequest {
return &UserVerificationRequest{
app: app,
resendThreshold: 120, // 2 min
return NewUserVerificationRequestWithConfig(UserVerificationRequestConfig{
App: app,
ResendThreshold: 120, // 2 min
})
}
// NewUserVerificationRequestWithConfig creates a new [UserVerificationRequest]
// form with the provided config or panics on invalid configuration.
func NewUserVerificationRequestWithConfig(config UserVerificationRequestConfig) *UserVerificationRequest {
form := &UserVerificationRequest{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,7 +78,7 @@ func (form *UserVerificationRequest) Submit() error {
return err
}
user, err := form.app.Dao().FindUserByEmail(form.Email)
user, err := form.config.TxDao.FindUserByEmail(form.Email)
if err != nil {
return err
}
@@ -59,16 +89,16 @@ func (form *UserVerificationRequest) Submit() error {
now := time.Now().UTC()
lastVerificationSentAt := user.LastVerificationSentAt.Time()
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
if (now.Sub(lastVerificationSentAt)).Seconds() < form.config.ResendThreshold {
return errors.New("A verification email was already sent.")
}
if err := mails.SendUserVerification(form.app, user); err != nil {
if err := mails.SendUserVerification(form.config.App, user); err != nil {
return err
}
// update last sent timestamp
user.LastVerificationSentAt = types.NowDateTime()
return form.app.Dao().SaveUser(user)
return form.config.TxDao.SaveUser(user)
}
+10
View File
@@ -11,6 +11,16 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestUserVerificationRequestPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserVerificationRequest(nil)
}
func TestUserVerificationRequestValidate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+14 -26
View File
@@ -240,7 +240,6 @@ func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, v
func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
val, _ := value.(types.DateTime)
if val.IsZero() {
if field.Required {
return requiredErr
@@ -268,6 +267,9 @@ func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField,
func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField, value any) error {
normalizedVal := list.ToUniqueStringSlice(value)
if len(normalizedVal) == 0 {
if field.Required {
return requiredErr
}
return nil // nothing to check
}
@@ -302,13 +304,9 @@ func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField,
}
func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField, value any) error {
// normalize value access
var names []string
switch v := value.(type) {
case []string:
names = v
case string:
names = []string{v}
names := list.ToUniqueStringSlice(value)
if len(names) == 0 && field.Required {
return requiredErr
}
options, _ := field.Options.(*schema.FileOptions)
@@ -343,16 +341,11 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
}
func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaField, value any) error {
// normalize value access
var ids []string
switch v := value.(type) {
case []string:
ids = v
case string:
ids = []string{v}
}
ids := list.ToUniqueStringSlice(value)
if len(ids) == 0 {
if field.Required {
return requiredErr
}
return nil // nothing to check
}
@@ -383,16 +376,11 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
}
func (validator *RecordDataValidator) checkUserValue(field *schema.SchemaField, value any) error {
// normalize value access
var ids []string
switch v := value.(type) {
case []string:
ids = v
case string:
ids = []string{v}
}
ids := list.ToUniqueStringSlice(value)
if len(ids) == 0 {
if field.Required {
return requiredErr
}
return nil // nothing to check
}
+62 -62
View File
@@ -89,7 +89,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint",
"(text) check required constraint",
map[string]any{
"field1": nil,
"field2": nil,
@@ -99,7 +99,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
[]string{"field2"},
},
{
"check unique constraint",
"(text) check unique constraint",
map[string]any{
"field1": "test",
"field2": "test",
@@ -109,7 +109,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
[]string{"field3"},
},
{
"check min constraint",
"(text) check min constraint",
map[string]any{
"field1": "test",
"field2": "test",
@@ -119,7 +119,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
[]string{"field3"},
},
{
"check max constraint",
"(text) check max constraint",
map[string]any{
"field1": "test",
"field2": "test",
@@ -129,7 +129,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
[]string{"field3"},
},
{
"check pattern constraint",
"(text) check pattern constraint",
map[string]any{
"field1": nil,
"field2": "test",
@@ -139,7 +139,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
[]string{"field3"},
},
{
"valid data (only required)",
"(text) valid data (only required)",
map[string]any{
"field2": "test",
},
@@ -147,7 +147,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
[]string{},
},
{
"valid data (all)",
"(text) valid data (all)",
map[string]any{
"field1": "test",
"field2": 12345, // test value cast
@@ -168,7 +168,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
// create new test collection
collection := &models.Collection{}
collection.Name = "validate_test"
min := 1.0
min := 0.0
max := 150.0
collection.Schema = schema.NewSchema(
&schema.SchemaField{
@@ -205,7 +205,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint",
"(number) check required constraint",
map[string]any{
"field1": nil,
"field2": nil,
@@ -215,17 +215,17 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint + casting",
"(number) check required constraint + casting",
map[string]any{
"field1": "invalid",
"field2": "invalid",
"field3": "invalid",
},
nil,
[]string{"field2", "field3"},
[]string{"field2"},
},
{
"check unique constraint",
"(number) check unique constraint",
map[string]any{
"field1": 123,
"field2": 123,
@@ -235,7 +235,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
[]string{"field3"},
},
{
"check min constraint",
"(number) check min constraint",
map[string]any{
"field1": 0.5,
"field2": 1,
@@ -245,7 +245,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
[]string{"field3"},
},
{
"check max constraint",
"(number) check max constraint",
map[string]any{
"field1": nil,
"field2": max,
@@ -255,7 +255,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
[]string{"field3"},
},
{
"valid data (only required)",
"(number) valid data (only required)",
map[string]any{
"field2": 1,
},
@@ -263,7 +263,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
[]string{},
},
{
"valid data (all)",
"(number) valid data (all)",
map[string]any{
"field1": nil,
"field2": 123, // test value cast
@@ -316,7 +316,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint",
"(bool) check required constraint",
map[string]any{
"field1": nil,
"field2": nil,
@@ -326,7 +326,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint + casting",
"(bool) check required constraint + casting",
map[string]any{
"field1": "invalid",
"field2": "invalid",
@@ -336,7 +336,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
[]string{"field2"},
},
{
"check unique constraint",
"(bool) check unique constraint",
map[string]any{
"field1": true,
"field2": true,
@@ -346,7 +346,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
[]string{"field3"},
},
{
"valid data (only required)",
"(bool) valid data (only required)",
map[string]any{
"field2": 1,
},
@@ -354,7 +354,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
[]string{},
},
{
"valid data (all)",
"(bool) valid data (all)",
map[string]any{
"field1": false,
"field2": true,
@@ -412,7 +412,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint",
"(email) check required constraint",
map[string]any{
"field1": nil,
"field2": nil,
@@ -422,7 +422,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
[]string{"field2"},
},
{
"check email format validator",
"(email) check email format validator",
map[string]any{
"field1": "test",
"field2": "test.com",
@@ -432,7 +432,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
[]string{"field1", "field2", "field3"},
},
{
"check unique constraint",
"(email) check unique constraint",
map[string]any{
"field1": "test@example.com",
"field2": "test@test.com",
@@ -442,7 +442,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
[]string{"field3"},
},
{
"check ExceptDomains constraint",
"(email) check ExceptDomains constraint",
map[string]any{
"field1": "test@example.com",
"field2": "test@example.com",
@@ -452,7 +452,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
[]string{"field2"},
},
{
"check OnlyDomains constraint",
"(email) check OnlyDomains constraint",
map[string]any{
"field1": "test@test.com",
"field2": "test@test.com",
@@ -462,7 +462,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
[]string{"field3"},
},
{
"valid data (only required)",
"(email) valid data (only required)",
map[string]any{
"field2": "test@test.com",
},
@@ -470,7 +470,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
[]string{},
},
{
"valid data (all)",
"(email) valid data (all)",
map[string]any{
"field1": "123@example.com",
"field2": "test@test.com",
@@ -528,7 +528,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint",
"(url) check required constraint",
map[string]any{
"field1": nil,
"field2": nil,
@@ -538,7 +538,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
[]string{"field2"},
},
{
"check url format validator",
"(url) check url format validator",
map[string]any{
"field1": "/abc",
"field2": "test.com", // valid
@@ -548,7 +548,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
[]string{"field1", "field3"},
},
{
"check unique constraint",
"(url) check unique constraint",
map[string]any{
"field1": "http://example.com",
"field2": "http://test.com",
@@ -558,7 +558,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
[]string{"field3"},
},
{
"check ExceptDomains constraint",
"(url) check ExceptDomains constraint",
map[string]any{
"field1": "http://example.com",
"field2": "http://example.com",
@@ -568,7 +568,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
[]string{"field2"},
},
{
"check OnlyDomains constraint",
"(url) check OnlyDomains constraint",
map[string]any{
"field1": "http://test.com/abc",
"field2": "http://test.com/abc",
@@ -578,7 +578,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
[]string{"field3"},
},
{
"check subdomains constraint",
"(url) check subdomains constraint",
map[string]any{
"field1": "http://test.test.com",
"field2": "http://test.example.com",
@@ -588,7 +588,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
[]string{"field3"},
},
{
"valid data (only required)",
"(url) valid data (only required)",
map[string]any{
"field2": "http://sub.test.com/abc",
},
@@ -596,7 +596,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
[]string{},
},
{
"valid data (all)",
"(url) valid data (all)",
map[string]any{
"field1": "http://example.com/123",
"field2": "http://test.com/",
@@ -656,7 +656,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint",
"(date) check required constraint",
map[string]any{
"field1": nil,
"field2": nil,
@@ -666,7 +666,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint + cast",
"(date) check required constraint + cast",
map[string]any{
"field1": "invalid",
"field2": "invalid",
@@ -676,7 +676,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint + zero datetime",
"(date) check required constraint + zero datetime",
map[string]any{
"field1": "January 1, year 1, 00:00:00 UTC",
"field2": "0001-01-01 00:00:00",
@@ -686,7 +686,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
[]string{"field2"},
},
{
"check unique constraint",
"(date) check unique constraint",
map[string]any{
"field1": "2029-01-01 01:01:01.123",
"field2": "2029-01-01 01:01:01.123",
@@ -696,7 +696,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
[]string{"field3"},
},
{
"check min date constraint",
"(date) check min date constraint",
map[string]any{
"field1": "2021-01-01 01:01:01",
"field2": "2021-01-01 01:01:01",
@@ -706,7 +706,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
[]string{"field2"},
},
{
"check max date constraint",
"(date) check max date constraint",
map[string]any{
"field1": "2030-02-01 01:01:01",
"field2": "2030-02-01 01:01:01",
@@ -716,7 +716,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
[]string{"field3"},
},
{
"valid data (only required)",
"(date) valid data (only required)",
map[string]any{
"field2": "2029-01-01 01:01:01",
},
@@ -724,7 +724,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
[]string{},
},
{
"valid data (all)",
"(date) valid data (all)",
map[string]any{
"field1": "2029-01-01 01:01:01.000",
"field2": "2029-01-01 01:01:01",
@@ -788,7 +788,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint",
"(select) check required constraint",
map[string]any{
"field1": nil,
"field2": nil,
@@ -798,7 +798,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint - empty values",
"(select) check required constraint - empty values",
map[string]any{
"field1": "",
"field2": "",
@@ -808,7 +808,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint - multiple select cast",
"(select) check required constraint - multiple select cast",
map[string]any{
"field1": "a",
"field2": "a",
@@ -818,7 +818,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{},
},
{
"check unique constraint",
"(select) check unique constraint",
map[string]any{
"field1": "a",
"field2": "b",
@@ -828,7 +828,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{"field3"},
},
{
"check unique constraint - same elements but different order",
"(select) check unique constraint - same elements but different order",
map[string]any{
"field1": "a",
"field2": "b",
@@ -838,7 +838,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{},
},
{
"check Values constraint",
"(select) check Values constraint",
map[string]any{
"field1": 1,
"field2": "d",
@@ -848,7 +848,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{"field2", "field3"},
},
{
"check MaxSelect constraint",
"(select) check MaxSelect constraint",
map[string]any{
"field1": []string{"a", "b"}, // this will be normalized to a single string value
"field2": []string{"a", "b", "c"},
@@ -858,7 +858,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{"field2"},
},
{
"valid data - only required fields",
"(select) valid data - only required fields",
map[string]any{
"field2": []string{"a", "b"},
},
@@ -866,7 +866,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
[]string{},
},
{
"valid data - all fields with normalizations",
"(select) valid data - all fields with normalizations",
map[string]any{
"field1": "a",
"field2": []string{"a", "b", "b"}, // will be collapsed
@@ -918,7 +918,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
scenarios := []testDataFieldScenario{
{
"check required constraint - nil",
"(json) check required constraint - nil",
map[string]any{
"field1": nil,
"field2": nil,
@@ -928,7 +928,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint - zero string",
"(json) check required constraint - zero string",
map[string]any{
"field1": "",
"field2": "",
@@ -938,7 +938,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{"field2"},
},
{
"check required constraint - zero number",
"(json) check required constraint - zero number",
map[string]any{
"field1": 0,
"field2": 0,
@@ -948,7 +948,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{},
},
{
"check required constraint - zero slice",
"(json) check required constraint - zero slice",
map[string]any{
"field1": []string{},
"field2": []string{},
@@ -958,7 +958,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{},
},
{
"check required constraint - zero map",
"(json) check required constraint - zero map",
map[string]any{
"field1": map[string]string{},
"field2": map[string]string{},
@@ -968,7 +968,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{},
},
{
"check unique constraint",
"(json) check unique constraint",
map[string]any{
"field1": `{"test":123}`,
"field2": `{"test":123}`,
@@ -978,7 +978,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{"field3"},
},
{
"check json text validator",
"(json) check json text validator",
map[string]any{
"field1": `[1, 2, 3`,
"field2": `invalid`,
@@ -988,7 +988,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{"field1", "field2"},
},
{
"valid data - only required fields",
"(json) valid data - only required fields",
map[string]any{
"field2": `{"test":123}`,
},
@@ -996,7 +996,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
[]string{},
},
{
"valid data - all fields with normalizations",
"(json) valid data - all fields with normalizations",
map[string]any{
"field1": []string{"a", "b", "c"},
"field2": 123,
+2 -2
View File
@@ -16,8 +16,8 @@ const AdminPasswordResetBody = `
<p>Follow this link to reset your admin password for {{.AppName}}.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p><i>If you did not request to reset your password, please ignore this email and the link will expire on its own.</i></p>
+2 -2
View File
@@ -14,8 +14,8 @@ const UserConfirmEmailChangeBody = `
<p>Hello,</p>
<p>Click on the button below to confirm your new email address.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Confirm new email</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Confirm new email</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p><i>If you didn’t ask to change your email address, you can ignore this email.</i></p>
<p>
+2 -2
View File
@@ -14,8 +14,8 @@ const UserPasswordResetBody = `
<p>Hello,</p>
<p>Click on the button below to reset your password.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p><i>If you didn’t ask to reset your password, you can ignore this email.</i></p>
<p>
+2 -2
View File
@@ -15,8 +15,8 @@ const UserVerificationBody = `
<p>Thank you for joining us at {{.AppName}}.</p>
<p>Click on the button below to verify your email address.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Verify</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Verify</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p>
Thanks,<br/>
+5
View File
@@ -96,6 +96,7 @@ func init() {
UpdateRule: &profileOwnerRule,
Schema: schema.NewSchema(
&schema.SchemaField{
Id: "pbfielduser",
Name: models.ProfileCollectionUserFieldName,
Type: schema.FieldTypeUser,
Unique: true,
@@ -107,11 +108,13 @@ func init() {
},
},
&schema.SchemaField{
Id: "pbfieldname",
Name: "name",
Type: schema.FieldTypeText,
Options: &schema.TextOptions{},
},
&schema.SchemaField{
Id: "pbfieldavatar",
Name: "avatar",
Type: schema.FieldTypeFile,
Options: &schema.FileOptions{
@@ -128,6 +131,8 @@ func init() {
},
),
}
collection.Id = "systemprofiles0"
collection.MarkAsNew()
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
+44 -9
View File
@@ -9,17 +9,30 @@ import (
"golang.org/x/crypto/bcrypt"
)
// DefaultIdLength is the default length of the generated model id.
const DefaultIdLength = 15
// ColumnValueMapper defines an interface for custom db model data serialization.
type ColumnValueMapper interface {
// ColumnValueMap returns the data to be used when persisting the model.
ColumnValueMap() map[string]any
}
// FilesManager defines an interface with common methods that files manager models should implement.
type FilesManager interface {
// BaseFilesPath returns the storage dir path used by the interface instance.
BaseFilesPath() string
}
// Model defines an interface with common methods that all db models should have.
type Model interface {
TableName() string
IsNew() bool
MarkAsNew()
UnmarkAsNew()
HasId() bool
GetId() string
SetId(id string)
GetCreated() types.DateTime
GetUpdated() types.DateTime
RefreshId()
@@ -33,45 +46,67 @@ type Model interface {
// BaseModel defines common fields and methods used by all other models.
type BaseModel struct {
isNewFlag bool
Id string `db:"id" json:"id"`
Created types.DateTime `db:"created" json:"created"`
Updated types.DateTime `db:"updated" json:"updated"`
}
// HasId returns whether the model has a nonzero primary key (aka. id).
// HasId returns whether the model has a nonzero id.
func (m *BaseModel) HasId() bool {
return m.GetId() != ""
}
// GetId returns the model's id.
// GetId returns the model id.
func (m *BaseModel) GetId() string {
return m.Id
}
// GetCreated returns the model's Created datetime.
// SetId sets the model id to the provided string value.
func (m *BaseModel) SetId(id string) {
m.Id = id
}
// MarkAsNew sets the model isNewFlag enforcing [m.IsNew()] to be true.
func (m *BaseModel) MarkAsNew() {
m.isNewFlag = true
}
// UnmarkAsNew resets the model isNewFlag.
func (m *BaseModel) UnmarkAsNew() {
m.isNewFlag = false
}
// IsNew indicates what type of db query (insert or update)
// should be used with the model instance.
func (m *BaseModel) IsNew() bool {
return m.isNewFlag || !m.HasId()
}
// GetCreated returns the model Created datetime.
func (m *BaseModel) GetCreated() types.DateTime {
return m.Created
}
// GetUpdated returns the model's Updated datetime.
// GetUpdated returns the model Updated datetime.
func (m *BaseModel) GetUpdated() types.DateTime {
return m.Updated
}
// RefreshId generates and sets a new model id.
//
// The generated id is a cryptographically random 15 characters length string
// (could change in the future).
// The generated id is a cryptographically random 15 characters length string.
func (m *BaseModel) RefreshId() {
m.Id = security.RandomString(15)
m.Id = security.RandomString(DefaultIdLength)
}
// RefreshCreated updates the model's Created field with the current datetime.
// RefreshCreated updates the model Created field with the current datetime.
func (m *BaseModel) RefreshCreated() {
m.Created = types.NowDateTime()
}
// RefreshUpdated updates the model's Updated field with the current datetime.
// RefreshUpdated updates the model Updated field with the current datetime.
func (m *BaseModel) RefreshUpdated() {
m.Updated = types.NowDateTime()
}
+43 -13
View File
@@ -34,25 +34,55 @@ func TestBaseModelHasId(t *testing.T) {
}
}
func TestBaseModelGetId(t *testing.T) {
m0 := models.BaseModel{}
if m0.GetId() != "" {
t.Fatalf("Expected zero id value, got %v", m0.GetId())
func TestBaseModelId(t *testing.T) {
m := models.BaseModel{}
if m.GetId() != "" {
t.Fatalf("Expected empty id value, got %v", m.GetId())
}
id := "abc"
m1 := models.BaseModel{Id: id}
if m1.GetId() != id {
t.Fatalf("Expected id %v, got %v", id, m1.GetId())
m.SetId("test")
if m.GetId() != "test" {
t.Fatalf("Expected %q id, got %v", "test", m.GetId())
}
m.RefreshId()
if len(m.GetId()) != 15 {
t.Fatalf("Expected 15 chars id, got %v", m.GetId())
}
}
func TestBaseModelRefreshId(t *testing.T) {
m := models.BaseModel{}
m.RefreshId()
func TestBaseModelIsNew(t *testing.T) {
m0 := models.BaseModel{}
m1 := models.BaseModel{Id: ""}
m2 := models.BaseModel{Id: "test"}
m3 := models.BaseModel{}
m3.MarkAsNew()
m4 := models.BaseModel{Id: "test"}
m4.MarkAsNew()
m5 := models.BaseModel{Id: "test"}
m5.MarkAsNew()
m5.UnmarkAsNew()
if m.GetId() == "" {
t.Fatalf("Expected nonempty id value, got %v", m.GetId())
scenarios := []struct {
model models.BaseModel
expected bool
}{
{m0, true},
{m1, true},
{m2, false},
{m3, true},
{m4, true},
{m5, false},
}
for i, s := range scenarios {
result := s.model.IsNew()
if result != s.expected {
t.Errorf("(%d) Expected IsNew %v, got %v", i, s.expected, result)
}
}
}
+1
View File
@@ -3,6 +3,7 @@ package models
import "github.com/pocketbase/pocketbase/models/schema"
var _ Model = (*Collection)(nil)
var _ FilesManager = (*Collection)(nil)
type Collection struct {
BaseModel
+1
View File
@@ -16,6 +16,7 @@ import (
var _ Model = (*Record)(nil)
var _ ColumnValueMapper = (*Record)(nil)
var _ FilesManager = (*Record)(nil)
type Record struct {
BaseModel
+3 -3
View File
@@ -118,7 +118,7 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
t.Fatal(err)
}
expected := `{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test","field2":null,"field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"c23eb053-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
expected := `{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"c23eb053-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
if string(encoded) != expected {
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
@@ -185,10 +185,10 @@ func TestNewRecordsFromNullStringMaps(t *testing.T) {
t.Fatal(err)
}
expected := `[{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test1","field2":null,"id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"},{"@collectionId":"","@collectionName":"test","created":"","field1":"test2","field2":123,"id":"22222222-d07e-4fbe-86b3-b8ac31982e9a","updated":""}]`
expected := `[{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test1","field2":0,"id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"},{"@collectionId":"","@collectionName":"test","created":"","field1":"test2","field2":123,"id":"22222222-d07e-4fbe-86b3-b8ac31982e9a","updated":""}]`
if string(encoded) != expected {
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
t.Fatalf("Expected \n%v, got \n%v", expected, string(encoded))
}
}
+13 -25
View File
@@ -218,31 +218,19 @@ func (f *SchemaField) PrepareValue(value any) any {
f.InitOptions()
switch f.Type {
case FieldTypeText, FieldTypeEmail, FieldTypeUrl: // string
if value == nil {
return nil
}
case FieldTypeText, FieldTypeEmail, FieldTypeUrl:
return cast.ToString(value)
case FieldTypeJson: // string
if value == nil {
return nil
}
case FieldTypeJson:
val, _ := types.ParseJsonRaw(value)
return val
case FieldTypeNumber: // nil, int or float
if value == nil {
return nil
}
case FieldTypeNumber:
return cast.ToFloat64(value)
case FieldTypeBool: // bool
case FieldTypeBool:
return cast.ToBool(value)
case FieldTypeDate: // string, DateTime or time.Time
if value == nil {
return nil
}
case FieldTypeDate:
val, _ := types.ParseDateTime(value)
return val
case FieldTypeSelect: // nil, string or slice of strings
case FieldTypeSelect:
val := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*SelectOptions)
@@ -250,11 +238,11 @@ func (f *SchemaField) PrepareValue(value any) any {
if len(val) > 0 {
return val[0]
}
return nil
return ""
}
return val
case FieldTypeFile: // nil, string or slice of strings
case FieldTypeFile:
val := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*FileOptions)
@@ -262,11 +250,11 @@ func (f *SchemaField) PrepareValue(value any) any {
if len(val) > 0 {
return val[0]
}
return nil
return ""
}
return val
case FieldTypeRelation: // nil, string or slice of strings
case FieldTypeRelation:
ids := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*RelationOptions)
@@ -274,11 +262,11 @@ func (f *SchemaField) PrepareValue(value any) any {
if len(ids) > 0 {
return ids[0]
}
return nil
return ""
}
return ids
case FieldTypeUser: // nil, string or slice of strings
case FieldTypeUser:
ids := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*UserOptions)
@@ -286,7 +274,7 @@ func (f *SchemaField) PrepareValue(value any) any {
if len(ids) > 0 {
return ids[0]
}
return nil
return ""
}
return ids
+20 -13
View File
@@ -498,19 +498,22 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: "unknown"}, []int{1, 2, 1}, "[1,2,1]"},
// text
{schema.SchemaField{Type: schema.FieldTypeText}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeText}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeText}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeText}, []int{1, 2}, `""`},
{schema.SchemaField{Type: schema.FieldTypeText}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeText}, 123, `"123"`},
// email
{schema.SchemaField{Type: schema.FieldTypeEmail}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, []int{1, 2}, `""`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, 123, `"123"`},
// url
{schema.SchemaField{Type: schema.FieldTypeUrl}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, []int{1, 2}, `""`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, 123, `"123"`},
@@ -523,7 +526,9 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeJson}, []int{1, 2, 1}, `[1,2,1]`},
// number
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "null"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "0"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, "", "0"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, "test", "0"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, 1, "1"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, 1.5, "1.5"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, "1.5", "1.5"},
@@ -533,22 +538,24 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeBool}, 1, "true"},
{schema.SchemaField{Type: schema.FieldTypeBool}, 0, "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "", "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "test", "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "false", "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "true", "true"},
{schema.SchemaField{Type: schema.FieldTypeBool}, false, "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, true, "true"},
// date
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, "null"},
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "test", `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, types.DateTime{}, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, time.Time{}, `""`},
// select (single)
{schema.SchemaField{Type: schema.FieldTypeSelect}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test1"`},
@@ -624,8 +631,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// file (single)
{schema.SchemaField{Type: schema.FieldTypeFile}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeFile}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeFile}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeFile}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test1"`},
@@ -690,8 +697,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// relation (single)
{schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "abc", `"abc"`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
@@ -761,8 +768,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// user (single)
{schema.SchemaField{Type: schema.FieldTypeUser}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeUser}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeUser}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
{
+69 -30
View File
@@ -20,6 +20,7 @@ import (
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
type join struct {
id string
table string
on dbx.Expression
}
@@ -36,7 +37,7 @@ type RecordFieldResolver struct {
baseCollection *models.Collection
allowedFields []string
requestData map[string]any
joins map[string]join
joins []join // we cannot use a map because the insertion order is not preserved
loadedCollections []*models.Collection
}
@@ -50,7 +51,7 @@ func NewRecordFieldResolver(
dao: dao,
baseCollection: baseCollection,
requestData: requestData,
joins: make(map[string]join),
joins: []join{},
loadedCollections: []*models.Collection{baseCollection},
allowedFields: []string{
`^\w+[\w\.]*$`,
@@ -85,6 +86,7 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
// id
// project.screen.status
// @request.status
// @request.user.profile.someRelation.name
// @collection.product.name
func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, placeholderParams dbx.Params, err error) {
if len(r.allowedFields) > 0 && !list.ExistInSliceWithRegex(fieldName, r.allowedFields) {
@@ -93,15 +95,6 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
props := strings.Split(fieldName, ".")
// check for @request field
if props[0] == "@request" {
if len(props) == 1 {
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
}
return r.resolveRequestField(props[1:]...)
}
currentCollectionName := r.baseCollection.Name
currentTableAlias := currentCollectionName
@@ -113,16 +106,54 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
}
currentCollectionName = props[1]
currentTableAlias = "c_" + currentCollectionName
currentTableAlias = "__collection_" + currentCollectionName
collection, err := r.loadCollection(currentCollectionName)
if err != nil {
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
}
r.addJoin(collection.Name, currentTableAlias, "", "", "")
r.addJoin(collection.Name, currentTableAlias, nil)
props = props[2:] // leave only the collection fields
} else if props[0] == "@request" {
// check for @request field
if len(props) == 1 {
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
}
// not a profile relational field
if len(props) <= 4 || !strings.HasPrefix(fieldName, "@request.user.profile.") {
return r.resolveStaticRequestField(props[1:]...)
}
// resolve the profile collection fields
currentCollectionName = models.ProfileCollectionName
currentTableAlias = "__user_" + currentCollectionName
collection, err := r.loadCollection(currentCollectionName)
if err != nil {
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
}
profileIdPlaceholder, profileIdPlaceholderParam, err := r.resolveStaticRequestField("user", "profile", "id")
if err != nil {
return "", nil, fmt.Errorf("Failed to resolve @request.user.profile.id path in %q.", fieldName)
}
if strings.ToLower(profileIdPlaceholder) == "null" {
// the user doesn't have an associated profile
return "NULL", nil, nil
}
// join the profile collection
r.addJoin(collection.Name, currentTableAlias, dbx.NewExp(fmt.Sprintf(
// aka. profiles.id = profileId
"[[%s.id]] = %s",
inflector.Columnify(currentTableAlias),
profileIdPlaceholder,
), profileIdPlaceholderParam))
props = props[3:] // leave only the profile fields
}
baseModelFields := schema.ReservedFieldNames()
@@ -173,9 +204,14 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
r.addJoin(
newCollectionName,
newTableAlias,
"id",
currentTableAlias,
field.Name,
dbx.NewExp(fmt.Sprintf(
// 'LIKE' expr is used to handle the case when the reference field supports multiple values (aka. is json array)
"[[%s.%s]] LIKE ('%%' || [[%s.%s]] || '%%')",
inflector.Columnify(currentTableAlias),
inflector.Columnify(field.Name),
inflector.Columnify(newTableAlias),
inflector.Columnify("id"),
)),
)
currentCollectionName = newCollectionName
@@ -185,7 +221,7 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
return "", nil, fmt.Errorf("Failed to resolve field %q.", fieldName)
}
func (r *RecordFieldResolver) resolveRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
// ignore error because requestData is dynamic and some of the
// lookup keys may not be defined for the request
resultVal, _ := extractNestedMapVal(r.requestData, path...)
@@ -259,24 +295,27 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models
return collection, nil
}
func (r *RecordFieldResolver) addJoin(tableName, tableAlias, fieldName, ref, refFieldName string) {
table := fmt.Sprintf(
func (r *RecordFieldResolver) addJoin(tableName string, tableAlias string, on dbx.Expression) {
tableExpr := fmt.Sprintf(
"%s %s",
inflector.Columnify(tableName),
inflector.Columnify(tableAlias),
)
var on dbx.Expression
if ref != "" {
on = dbx.NewExp(fmt.Sprintf(
// 'LIKE' expr is used to handle the case when the reference field supports multiple values (aka. is json array)
"[[%s.%s]] LIKE ('%%' || [[%s.%s]] || '%%')",
inflector.Columnify(ref),
inflector.Columnify(refFieldName),
inflector.Columnify(tableAlias),
inflector.Columnify(fieldName),
))
join := join{
id: tableAlias,
table: tableExpr,
on: on,
}
r.joins[tableAlias] = join{table, on}
// replace existing join
for i, j := range r.joins {
if j.id == join.id {
r.joins[i] = join
return
}
}
// register new join
r.joins = append(r.joins, join)
}
+116 -63
View File
@@ -2,11 +2,12 @@ package resolvers_test
import (
"encoding/json"
"strings"
"regexp"
"testing"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
@@ -18,54 +19,94 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
t.Fatal(err)
}
requestData := map[string]any{
"user": map[string]any{
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
"profile": map[string]any{
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
"name": "test",
},
},
}
scenarios := []struct {
fieldName string
expectQueryParts []string // we are matching parts of the query
// since joins are added with map iteration and the order is not guaranteed
name string
fields []string
expectQuery string
}{
// missing field
{"", []string{
{
"missing field",
[]string{""},
"SELECT `demo4`.* FROM `demo4`",
}},
// non relation field
{"title", []string{
},
{
"non relation field",
[]string{"title"},
"SELECT `demo4`.* FROM `demo4`",
}},
// incomplete rel
{"onerel", []string{
},
{
"incomplete rel",
[]string{"onerel"},
"SELECT `demo4`.* FROM `demo4`",
}},
// single rel
{"onerel.title", []string{
"SELECT DISTINCT `demo4`.* FROM `demo4`",
" LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
}},
// nested incomplete rels
{"manyrels.onerel", []string{
"SELECT DISTINCT `demo4`.* FROM `demo4`",
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
}},
// nested complete rels
{"manyrels.onerel.title", []string{
"SELECT DISTINCT `demo4`.* FROM `demo4`",
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
" LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
}},
// // repeated nested rels
{"manyrels.onerel.manyrels.onerel.title", []string{
"SELECT DISTINCT `demo4`.* FROM `demo4`",
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
" LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
" LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels` ON [[demo4_manyrels_onerel.manyrels]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels.id]] || '%')",
" LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels_onerel` ON [[demo4_manyrels_onerel_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels_onerel.id]] || '%')",
}},
},
{
"single rel",
[]string{"onerel.title"},
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
},
{
"non-relation field + single rel",
[]string{"title", "onerel.title"},
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
},
{
"nested incomplete rels",
[]string{"manyrels.onerel"},
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
},
{
"nested complete rels",
[]string{"manyrels.onerel.title"},
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
},
{
"repeated nested rels",
[]string{"manyrels.onerel.manyrels.onerel.title"},
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels` ON [[demo4_manyrels_onerel.manyrels]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels_onerel` ON [[demo4_manyrels_onerel_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels_onerel.id]] || '%')",
},
{
"multiple rels",
[]string{"manyrels.title", "onerel.onefile"},
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
},
{
"@collection join",
[]string{"@collection.demo.title", "@collection.demo2.text", "@collection.demo.file"},
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo` `__collection_demo` LEFT JOIN `demo2` `__collection_demo2`",
},
{
"static @request.user.profile fields",
[]string{"@request.user.id", "@request.user.profile.id", "@request.data.demo"},
"SELECT `demo4`.* FROM `demo4`",
},
{
"relational @request.user.profile fields",
[]string{"@request.user.profile.rel.id", "@request.user.profile.rel.name"},
"^" +
regexp.QuoteMeta("SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `profiles` `__user_profiles` ON [[__user_profiles.id]] =") +
" {:.*} " +
regexp.QuoteMeta("LEFT JOIN `profiles` `__user_profiles_rel` ON [[__user_profiles.rel]] LIKE ('%' || [[__user_profiles_rel.id]] || '%')") +
"$",
},
}
for i, s := range scenarios {
query := app.Dao().RecordQuery(collection)
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
r.Resolve(s.fieldName)
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
for _, field := range s.fields {
r.Resolve(field)
}
if err := r.UpdateQuery(query); err != nil {
t.Errorf("(%d) UpdateQuery failed with error %v", i, err)
@@ -74,16 +115,8 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
rawQuery := query.Build().SQL()
partsLength := 0
for _, part := range s.expectQueryParts {
partsLength += len(part)
if !strings.Contains(rawQuery, part) {
t.Errorf("(%d) Part %v is missing from query \n%v", i, part, rawQuery)
}
}
if partsLength != len(rawQuery) {
t.Errorf("(%d) Expected %d characters, got %d in \n%v", i, partsLength, len(rawQuery), rawQuery)
if !list.ExistInSliceWithRegex(rawQuery, []string{s.expectQuery}) {
t.Errorf("(%d) Expected query\n %v \ngot:\n %v", i, s.expectQuery, rawQuery)
}
}
}
@@ -97,7 +130,16 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
t.Fatal(err)
}
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
requestData := map[string]any{
"user": map[string]any{
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
"profile": map[string]any{
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
},
},
}
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
scenarios := []struct {
fieldName string
@@ -118,42 +160,45 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
{"manyrels.unknown", true, ""},
{"manyrels.title", false, "[[demo4_manyrels.title]]"},
{"manyrels.onerel.manyrels.onefile", false, "[[demo4_manyrels_onerel_manyrels.onefile]]"},
// @request.user.profile relation join:
{"@request.user.profile.rel.name", false, "[[__user_profiles_rel.name]]"},
// @collection fieds:
{"@collect", true, ""},
{"collection.demo4.title", true, ""},
{"@collection", true, ""},
{"@collection.unknown", true, ""},
{"@collection.demo", true, ""},
{"@collection.demo.", true, ""},
{"@collection.demo.title", false, "[[c_demo.title]]"},
{"@collection.demo4.title", false, "[[c_demo4.title]]"},
{"@collection.demo4.id", false, "[[c_demo4.id]]"},
{"@collection.demo4.created", false, "[[c_demo4.created]]"},
{"@collection.demo4.updated", false, "[[c_demo4.updated]]"},
{"@collection.demo.title", false, "[[__collection_demo.title]]"},
{"@collection.demo4.title", false, "[[__collection_demo4.title]]"},
{"@collection.demo4.id", false, "[[__collection_demo4.id]]"},
{"@collection.demo4.created", false, "[[__collection_demo4.created]]"},
{"@collection.demo4.updated", false, "[[__collection_demo4.updated]]"},
{"@collection.demo4.manyrels.missing", true, ""},
{"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[c_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
{"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[__collection_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
}
for i, s := range scenarios {
for _, s := range scenarios {
name, params, err := r.Resolve(s.fieldName)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
t.Errorf("(%q) Expected hasErr %v, got %v (%v)", s.fieldName, s.expectError, hasErr, err)
continue
}
if name != s.expectName {
t.Errorf("(%d) Expected name %q, got %q", i, s.expectName, name)
t.Errorf("(%q) Expected name %q, got %q", s.fieldName, s.expectName, name)
}
// params should be empty for non @request fields
if len(params) != 0 {
t.Errorf("(%d) Expected 0 params, got %v", i, params)
t.Errorf("(%q) Expected 0 params, got %v", s.fieldName, params)
}
}
}
func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -171,7 +216,13 @@ func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
"b": 456,
"c": map[string]int{"sub": 1},
},
"user": nil,
"user": map[string]any{
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
"profile": map[string]any{
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
"name": "test",
},
},
}
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
@@ -194,7 +245,9 @@ func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
{"@request.data.b.missing", false, ``},
{"@request.data.c", false, `"{\"sub\":1}"`},
{"@request.user", true, ""},
{"@request.user.id", false, ""},
{"@request.user.id", false, `"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`},
{"@request.user.profile", false, `"{\"id\":\"d13f60a4-5765-48c7-9e1d-3e782340f833\",\"name\":\"test\"}"`},
{"@request.user.profile.name", false, `"test"`},
}
for i, s := range scenarios {
+27 -7
View File
@@ -22,11 +22,20 @@ type ApiScenario struct {
Url string
Body io.Reader
RequestHeaders map[string]string
// Delay adds a delay before checking the expectations usually
// to ensure that all fired non-awaited go routines have finished
Delay time.Duration
// expectations
ExpectedStatus int
ExpectedContent []string
ExpectedEvents map[string]int
// test events
// ---
ExpectedStatus int
ExpectedContent []string
NotExpectedContent []string
ExpectedEvents map[string]int
// test hooks
// ---
BeforeFunc func(t *testing.T, app *TestApp, e *echo.Echo)
AfterFunc func(t *testing.T, app *TestApp, e *echo.Echo)
}
@@ -80,9 +89,13 @@ func (scenario *ApiScenario) Test(t *testing.T) {
t.Errorf("[%s] Expected status code %d, got %d", prefix, scenario.ExpectedStatus, res.StatusCode)
}
if len(scenario.ExpectedContent) == 0 {
if scenario.Delay > 0 {
time.Sleep(scenario.Delay)
}
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
if len(recorder.Body.Bytes()) != 0 {
t.Errorf("[%s] Expected empty body, got %v", prefix, recorder.Body.String())
t.Errorf("[%s] Expected empty body, got \n%v", prefix, recorder.Body.String())
}
} else {
// normalize json response format
@@ -98,7 +111,14 @@ func (scenario *ApiScenario) Test(t *testing.T) {
for _, item := range scenario.ExpectedContent {
if !strings.Contains(normalizedBody, item) {
t.Errorf("[%s] Cannot find %v in response body %v", prefix, item, normalizedBody)
t.Errorf("[%s] Cannot find %v in response body \n%v", prefix, item, normalizedBody)
break
}
}
for _, item := range scenario.NotExpectedContent {
if strings.Contains(normalizedBody, item) {
t.Errorf("[%s] Didn't expect %v in response body \n%v", prefix, item, normalizedBody)
break
}
}
+10
View File
@@ -313,6 +313,16 @@ func NewTestApp() (*TestApp, error) {
return nil
})
t.OnCollectionsBeforeImportRequest().Add(func(e *core.CollectionsImportEvent) error {
t.EventCalls["OnCollectionsBeforeImportRequest"]++
return nil
})
t.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error {
t.EventCalls["OnCollectionsAfterImportRequest"]++
return nil
})
t.OnAdminsListRequest().Add(func(e *core.AdminsListEvent) error {
t.EventCalls["OnAdminsListRequest"]++
return nil
Binary file not shown.
+1 -1
View File
@@ -21,7 +21,7 @@ type Github struct {
// NewGithubProvider creates new Github provider instance with some defaults.
func NewGithubProvider() *Github {
return &Github{&baseProvider{
scopes: []string{"user"},
scopes: []string{"read:user", "user:email"},
authUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userApiUrl: "https://api.github.com/user",
+35 -6
View File
@@ -16,6 +16,7 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/disintegration/imaging"
"github.com/pocketbase/pocketbase/tools/list"
"gocloud.dev/blob"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/s3blob"
@@ -35,15 +36,17 @@ func NewS3(
endpoint string,
accessKey string,
secretKey string,
s3ForcePathStyle bool,
) (*System, error) {
ctx := context.Background() // default context
cred := credentials.NewStaticCredentials(accessKey, secretKey, "")
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Endpoint: aws.String(endpoint),
Credentials: cred,
Region: aws.String(region),
Endpoint: aws.String(endpoint),
Credentials: cred,
S3ForcePathStyle: aws.Bool(s3ForcePathStyle),
})
if err != nil {
return nil, err
@@ -153,7 +156,7 @@ func (s *System) DeletePrefix(prefix string) []error {
// (this operation usually is optional and there is no need to strictly check the result)
// ---
// fill dirs slice
dirs := []string{}
dirs := make([]string, 0, len(dirsMap))
for d := range dirsMap {
dirs = append(dirs, d)
}
@@ -174,6 +177,17 @@ func (s *System) DeletePrefix(prefix string) []error {
return failed
}
var inlineServeContentTypes = []string{
// image
"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/bmp",
// video
"video/webm", "video/mp4", "video/3gpp", "video/quicktime", "video/x-ms-wmv",
// audio
"audio/basic", "audio/aiff", "audio/mpeg", "audio/midi", "audio/wave",
// document
"application/pdf",
}
// Serve serves the file at fileKey location to an HTTP response.
func (s *System) Serve(response http.ResponseWriter, fileKey string, name string) error {
r, readErr := s.bucket.NewReader(s.ctx, fileKey, nil)
@@ -182,9 +196,24 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string
}
defer r.Close()
response.Header().Set("Content-Disposition", "attachment; filename="+name)
response.Header().Set("Content-Type", r.ContentType())
disposition := "attachment"
realContentType := r.ContentType()
if list.ExistInSlice(realContentType, inlineServeContentTypes) {
disposition = "inline"
}
// make an exception for svg and use a custom content type
// to send in the response so that it can be loaded in a img tag
// (see https://github.com/whatwg/mimesniff/issues/7)
extContentType := realContentType
if filepath.Ext(name) == ".svg" {
extContentType = "image/svg+xml"
}
response.Header().Set("Content-Disposition", disposition+"; filename="+name)
response.Header().Set("Content-Type", extContentType)
response.Header().Set("Content-Length", strconv.FormatInt(r.Size(), 10))
response.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
// All HTTP date/time stamps MUST be represented in Greenwich Mean Time (GMT)
// (see https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1)
+91 -38
View File
@@ -28,7 +28,7 @@ func TestFileSystemExists(t *testing.T) {
{"sub1.txt", false},
{"test/sub1.txt", true},
{"test/sub2.txt", true},
{"file.png", true},
{"image.png", true},
}
for i, scenario := range scenarios {
@@ -51,13 +51,14 @@ func TestFileSystemAttributes(t *testing.T) {
defer fs.Close()
scenarios := []struct {
file string
expectError bool
file string
expectError bool
expectContentType string
}{
{"sub1.txt", true},
{"test/sub1.txt", false},
{"test/sub2.txt", false},
{"file.png", false},
{"sub1.txt", true, ""},
{"test/sub1.txt", false, "application/octet-stream"},
{"test/sub2.txt", false, "application/octet-stream"},
{"image.png", false, "image/png"},
}
for i, scenario := range scenarios {
@@ -71,8 +72,8 @@ func TestFileSystemAttributes(t *testing.T) {
t.Errorf("(%d) Expected nil, got error, %v", i, err)
}
if err == nil && attr.ContentType != "application/octet-stream" {
t.Errorf("(%d) Expected attr.ContentType to be %q, got %q", i, "application/octet-stream", attr.ContentType)
if err == nil && attr.ContentType != scenario.expectContentType {
t.Errorf("(%d) Expected attr.ContentType to be %q, got %q", i, scenario.expectContentType, attr.ContentType)
}
}
}
@@ -91,7 +92,7 @@ func TestFileSystemDelete(t *testing.T) {
t.Fatal("Expected error, got nil")
}
if err := fs.Delete("file.png"); err != nil {
if err := fs.Delete("image.png"); err != nil {
t.Fatalf("Expected nil, got error %v", err)
}
}
@@ -157,33 +158,75 @@ func TestFileSystemServe(t *testing.T) {
}
defer fs.Close()
r := httptest.NewRecorder()
// serve missing file
if err := fs.Serve(r, "missing.txt", "download.txt"); err == nil {
t.Fatal("Expected error, got nil")
}
// serve existing file
if err := fs.Serve(r, "test/sub1.txt", "download.txt"); err != nil {
t.Fatal("Expected nil, got error")
}
result := r.Result()
// check headers
scenarios := []struct {
header string
expected string
path string
name string
expectError bool
expectHeaders map[string]string
}{
{"Content-Disposition", "attachment; filename=download.txt"},
{"Content-Type", "application/octet-stream"},
{"Content-Length", "0"},
{
// missing
"missing.txt",
"test_name.txt",
true,
nil,
},
{
// existing regular file
"test/sub1.txt",
"test_name.txt",
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.txt",
"Content-Type": "application/octet-stream",
"Content-Length": "0",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
},
},
{
// png inline
"image.png",
"test_name.png",
false,
map[string]string{
"Content-Disposition": "inline; filename=test_name.png",
"Content-Type": "image/png",
"Content-Length": "73",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
},
},
{
// svg exception
"image.svg",
"test_name.svg",
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.svg",
"Content-Type": "image/svg+xml",
"Content-Length": "0",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
},
},
}
for i, scenario := range scenarios {
v := result.Header.Get(scenario.header)
if v != scenario.expected {
t.Errorf("(%d) Expected value %q for header %q, got %q", i, scenario.expected, scenario.header, v)
for _, scenario := range scenarios {
r := httptest.NewRecorder()
err := fs.Serve(r, scenario.path, scenario.name)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("(%s) Expected hasError %v, got %v", scenario.path, scenario.expectError, hasErr)
continue
}
result := r.Result()
for hName, hValue := range scenario.expectHeaders {
v := result.Header.Get(hName)
if v != hValue {
t.Errorf("(%s) Expected value %q for header %q, got %q", scenario.path, hValue, hName, v)
}
}
}
}
@@ -209,11 +252,11 @@ func TestFileSystemCreateThumb(t *testing.T) {
// non-image existing file
{"test/sub1.txt", "thumb_test_sub1", true, true},
// existing image file - crop center
{"file.png", "thumb_file_center", true, false},
{"image.png", "thumb_file_center", true, false},
// existing image file - crop top
{"file.png", "thumb_file_top", false, false},
{"image.png", "thumb_file_top", false, false},
// existing image file with existing thumb path = should fail
{"file.png", "test", true, true},
{"image.png", "test", true, true},
}
for i, scenario := range scenarios {
@@ -259,7 +302,7 @@ func createTestDir(t *testing.T) string {
}
file2.Close()
file3, err := os.OpenFile(filepath.Join(dir, "file.png"), os.O_WRONLY|os.O_CREATE, 0666)
file3, err := os.OpenFile(filepath.Join(dir, "image.png"), os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
t.Fatal(err)
}
@@ -267,6 +310,16 @@ func createTestDir(t *testing.T) string {
imgRect := image.Rect(0, 0, 1, 1)
png.Encode(file3, imgRect)
file3.Close()
err2 := os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0666)
if err2 != nil {
t.Fatal(err2)
}
file4, err := os.OpenFile(filepath.Join(dir, "image.svg"), os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
t.Fatal(err)
}
file4.Close()
return dir
}
+4 -5
View File
@@ -32,15 +32,14 @@ func Sentenize(str string) string {
return ""
}
s := []rune(str)
sentence := string(unicode.ToUpper(s[0])) + string(s[1:])
str = UcFirst(str)
lastChar := string(s[len(s)-1:])
lastChar := str[len(str)-1:]
if lastChar != "." && lastChar != "?" && lastChar != "!" {
return sentence + "."
return str + "."
}
return sentence
return str
}
// Sanitize sanitizes `str` by removing all characters satisfying `removePattern`.
+4
View File
@@ -12,6 +12,7 @@ func TestUcFirst(t *testing.T) {
expected string
}{
{"", ""},
{" ", " "},
{"Test", "Test"},
{"test", "Test"},
{"test test2", "Test test2"},
@@ -55,6 +56,9 @@ func TestSentenize(t *testing.T) {
}{
{"", ""},
{" ", ""},
{".", "."},
{"?", "?"},
{"!", "!"},
{"Test", "Test."},
{" test ", "Test."},
{"hello world", "Hello world."},
+6 -63
View File
@@ -2,14 +2,11 @@ package migrate
import (
"fmt"
"os"
"path"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/spf13/cast"
)
@@ -40,9 +37,8 @@ func NewRunner(db *dbx.DB, migrationsList MigrationsList) (*Runner, error) {
// Run interactively executes the current runner with the provided args.
//
// The following commands are supported:
// - up - applies all migrations
// - down [n] - reverts the last n applied migrations
// - create NEW_MIGRATION_NAME - create NEW_MIGRATION_NAME.go file from a migration template
// - up - applies all migrations
// - down [n] - reverts the last n applied migrations
func (r *Runner) Run(args ...string) error {
cmd := "up"
if len(args) > 0 {
@@ -100,57 +96,6 @@ func (r *Runner) Run(args ...string) error {
}
}
return nil
case "create":
if len(args) < 2 {
return fmt.Errorf("Missing migration file name")
}
name := args[1]
var dir string
if len(args) == 3 {
dir = args[2]
}
if dir == "" {
// If not specified, auto point to the default migrations folder.
//
// NB!
// Since the create command makes sense only during development,
// it is expected the user to be in the app working directory
// and to be using `go run ...`
wd, err := os.Getwd()
if err != nil {
return err
}
dir = path.Join(wd, "migrations")
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
)
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
// ensure that migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
if err := os.WriteFile(resultFilePath, []byte(createTemplateContent), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
default:
return fmt.Errorf("Unsupported command: %q\n", cmd)
@@ -194,11 +139,9 @@ func (r *Runner) Up() ([]string, error) {
//
// On success returns list with the reverted migrations file names.
func (r *Runner) Down(toRevertCount int) ([]string, error) {
applied := []string{}
reverted := make([]string, 0, toRevertCount)
err := r.db.Transactional(func(tx *dbx.Tx) error {
totalReverted := 0
for i := len(r.migrationsList.Items()) - 1; i >= 0; i-- {
m := r.migrationsList.Item(i)
@@ -208,7 +151,7 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
}
// revert limit reached
if toRevertCount-totalReverted <= 0 {
if toRevertCount-len(reverted) <= 0 {
break
}
@@ -220,7 +163,7 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.file, err)
}
applied = append(applied, m.file)
reverted = append(reverted, m.file)
}
return nil
@@ -229,7 +172,7 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
if err != nil {
return nil, err
}
return applied, nil
return reverted, nil
}
func (r *Runner) createMigrationsTable() error {
+8 -3
View File
@@ -104,7 +104,8 @@ func TestRunnerUpAndDown(t *testing.T) {
// Down()
// ---
if _, err := r.Down(2); err != nil {
// revert one migration
if _, err := r.Down(1); err != nil {
t.Fatal(err)
}
@@ -112,8 +113,12 @@ func TestRunnerUpAndDown(t *testing.T) {
t.Fatal("Didn't expect 3_test to be reverted.")
}
if !test1DownCalled || !test2DownCalled {
t.Fatalf("Expected 1_test and 2_test to be reverted, got %v and %v", test1DownCalled, test2DownCalled)
if !test2DownCalled {
t.Fatal("Expected 2_test to be reverted.")
}
if test1DownCalled {
t.Fatal("Didn't expect 1_test to be reverted.")
}
}
-21
View File
@@ -1,21 +0,0 @@
package migrate
const createTemplateContent = `package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
+6 -1
View File
@@ -7,6 +7,7 @@ import (
"mime/multipart"
"net/http"
"path/filepath"
"regexp"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -15,6 +16,8 @@ import (
// will be used when parsing a form request body.
const DefaultMaxMemory = 32 << 20 // 32mb
var extensionInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`)
// UploadedFile defines a single multipart uploaded file instance.
type UploadedFile struct {
name string
@@ -65,8 +68,10 @@ func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
return nil, err
}
ext := extensionInvalidCharsRegex.ReplaceAllString(filepath.Ext(fh.Filename), "")
result[i] = &UploadedFile{
name: fmt.Sprintf("%s%s", security.RandomString(32), filepath.Ext(fh.Filename)),
name: fmt.Sprintf("%s%s", security.RandomString(32), ext),
header: fh,
bytes: buf.Bytes(),
}
+9 -11
View File
@@ -98,17 +98,9 @@ func (f FilterData) resolveTokenizedExpr(expr fexpr.Expr, fieldResolver FieldRes
switch expr.Op {
case fexpr.SignEq:
op := "="
if strings.ToLower(lName) == "null" || strings.ToLower(rName) == "null" {
op = "IS"
}
return dbx.NewExp(fmt.Sprintf("%s %s %s", lName, op, rName), params), nil
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') = COALESCE(%s, '')", lName, rName), params), nil
case fexpr.SignNeq:
op := "!="
if strings.ToLower(lName) == "null" || strings.ToLower(rName) == "null" {
op = "IS NOT"
}
return dbx.NewExp(fmt.Sprintf("%s %s %s", lName, op, rName), params), nil
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') != COALESCE(%s, '')", lName, rName), params), nil
case fexpr.SignLike:
// normalize operands and switch sides if the left operand is a number or text
if len(lParams) > 0 {
@@ -155,11 +147,17 @@ func (f FilterData) resolveToken(token fexpr.Token, fieldResolver FieldResolver)
}
return name, params, err
case fexpr.TokenNumber, fexpr.TokenText:
case fexpr.TokenText:
placeholder := "t" + security.RandomString(7)
name := fmt.Sprintf("{:%s}", placeholder)
params := dbx.Params{placeholder: token.Literal}
return name, params, nil
case fexpr.TokenNumber:
placeholder := "t" + security.RandomString(7)
name := fmt.Sprintf("{:%s}", placeholder)
params := dbx.Params{placeholder: cast.ToFloat64(token.Literal)}
return name, params, nil
}
+5 -5
View File
@@ -39,25 +39,25 @@ func TestFilterDataBuildExpr(t *testing.T) {
"^" +
regexp.QuoteMeta("((([[test1]] > {:") +
".+" +
regexp.QuoteMeta("}) OR ([[test2]] != {:") +
regexp.QuoteMeta("}) OR (COALESCE([[test2]], '') != COALESCE({:") +
".+" +
regexp.QuoteMeta("})) AND ([[test3]] LIKE {:") +
regexp.QuoteMeta("}, ''))) AND ([[test3]] LIKE {:") +
".+" +
regexp.QuoteMeta("})) AND ([[test4.sub]] IS NULL)") +
regexp.QuoteMeta("})) AND (COALESCE([[test4.sub]], '') = COALESCE(NULL, ''))") +
"$",
},
// combination of special literals (null, true, false)
{
"test1=true && test2 != false && test3 = null || test4.sub != null",
false,
"^" + regexp.QuoteMeta("((([[test1]] = 1) AND ([[test2]] != 0)) AND ([[test3]] IS NULL)) OR ([[test4.sub]] IS NOT NULL)") + "$",
"^" + regexp.QuoteMeta("(((COALESCE([[test1]], '') = COALESCE(1, '')) AND (COALESCE([[test2]], '') != COALESCE(0, ''))) AND (COALESCE([[test3]], '') = COALESCE(NULL, ''))) OR (COALESCE([[test4.sub]], '') != COALESCE(NULL, ''))") + "$",
},
// all operators
{
"(test1 = test2 || test2 != test3) && (test2 ~ 'example' || test2 !~ '%%abc') && 'switch1%%' ~ test1 && 'switch2' !~ test2 && test3 > 1 && test3 >= 0 && test3 <= 4 && 2 < 5",
false,
"^" +
regexp.QuoteMeta("(((((((([[test1]] = [[test2]]) OR ([[test2]] != [[test3]])) AND (([[test2]] LIKE {:") +
regexp.QuoteMeta("((((((((COALESCE([[test1]], '') = COALESCE([[test2]], '')) OR (COALESCE([[test2]], '') != COALESCE([[test3]], ''))) AND (([[test2]] LIKE {:") +
".+" +
regexp.QuoteMeta("}) OR ([[test2]] NOT LIKE {:") +
".+" +
+18 -2
View File
@@ -28,6 +28,7 @@ type Result struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items any `json:"items"`
}
@@ -37,6 +38,7 @@ type Provider struct {
query *dbx.SelectQuery
page int
perPage int
countColumn string
sort []SortField
filter []FilterData
}
@@ -67,6 +69,13 @@ func (s *Provider) Query(query *dbx.SelectQuery) *Provider {
return s
}
// CountColumn specifies an optional distinct column to use in the
// SELECT COUNT query.
func (s *Provider) CountColumn(countColumn string) *Provider {
s.countColumn = countColumn
return s
}
// Page sets the `page` field of the current search provider.
//
// Normalization on the `page` value is done during `Exec()`.
@@ -190,7 +199,11 @@ func (s *Provider) Exec(items any) (*Result, error) {
// count
var totalCount int64
countQuery := modelsQuery
if err := countQuery.Select("count(*)").Row(&totalCount); err != nil {
countQuery.Distinct(false).Select("COUNT(*)")
if s.countColumn != "" {
countQuery.Select("COUNT(DISTINCT(" + s.countColumn + "))")
}
if err := countQuery.Row(&totalCount); err != nil {
return nil, err
}
@@ -201,10 +214,12 @@ func (s *Provider) Exec(items any) (*Result, error) {
s.perPage = MaxPerPage
}
totalPages := int(math.Ceil(float64(totalCount) / float64(s.perPage)))
// normalize page according to the total count
if s.page <= 0 || totalCount == 0 {
s.page = 1
} else if totalPages := int(math.Ceil(float64(totalCount) / float64(s.perPage))); s.page > totalPages {
} else if s.page > totalPages {
s.page = totalPages
}
@@ -221,6 +236,7 @@ func (s *Provider) Exec(items any) (*Result, error) {
Page: s.page,
PerPage: s.perPage,
TotalItems: int(totalCount),
TotalPages: totalPages,
Items: items,
}, nil
}
+48 -16
View File
@@ -60,6 +60,15 @@ func TestProviderPerPage(t *testing.T) {
}
}
func TestProviderCountColumn(t *testing.T) {
r := &testFieldResolver{}
p := NewProvider(r).CountColumn("test")
if p.countColumn != "test" {
t.Fatalf("Expected distinct count column %v, got %v", "test", p.countColumn)
}
}
func TestProviderSort(t *testing.T) {
initialSort := []SortField{{"test1", SortAsc}, {"test2", SortAsc}}
r := &testFieldResolver{}
@@ -214,6 +223,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
perPage int
sort []SortField
filter []FilterData
countColumn string
expectError bool
expectResult string
expectQueries []string
@@ -224,10 +234,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
10,
[]SortField{},
[]FilterData{},
"",
false,
`{"page":1,"perPage":10,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":10,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10",
},
},
@@ -237,10 +248,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
0, // fallback to default
[]SortField{},
[]FilterData{},
"",
false,
`{"page":1,"perPage":30,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":30,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30",
},
},
@@ -250,6 +262,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
10,
[]SortField{{"unknown", SortAsc}},
[]FilterData{},
"",
true,
"",
nil,
@@ -260,6 +273,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
10,
[]SortField{},
[]FilterData{"test2 = 'test2.1'", "invalid"},
"",
true,
"",
nil,
@@ -270,11 +284,12 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
5555, // will be limited by MaxPerPage
[]SortField{{"test2", SortDesc}},
[]FilterData{"test2 != null", "test1 >= 2"},
"",
false,
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT count(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (test2 IS NOT null)) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC",
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (test2 IS NOT null)) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
"SELECT COUNT(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC",
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
},
},
// valid sort and filter fields (zero results)
@@ -283,11 +298,12 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
10,
[]SortField{{"test3", SortAsc}},
[]FilterData{"test3 != ''"},
"",
false,
`{"page":1,"perPage":10,"totalItems":0,"items":[]}`,
`{"page":1,"perPage":10,"totalItems":0,"totalPages":0,"items":[]}`,
[]string{
"SELECT count(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (test3 != '') ORDER BY `test1` ASC, `test3` ASC",
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (test3 != '') ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
"SELECT COUNT(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC",
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
},
},
// pagination test
@@ -296,10 +312,25 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
1,
[]SortField{},
[]FilterData{},
"",
false,
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":2,"perPage":1,"totalItems":2,"totalPages":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
},
},
// distinct count column
{
3,
1,
[]SortField{},
[]FilterData{},
"test.test1",
false,
`{"page":2,"perPage":1,"totalItems":2,"totalPages":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT COUNT(DISTINCT(test.test1)) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
},
},
@@ -314,7 +345,8 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
Page(s.page).
PerPage(s.perPage).
Sort(s.sort).
Filter(s.filter)
Filter(s.filter).
CountColumn(s.countColumn)
result, err := p.Exec(&[]testTableStruct{})
@@ -344,7 +376,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
for _, q := range testDB.CalledQueries {
if !list.ExistInSliceWithRegex(q, s.expectQueries) {
t.Errorf("(%d) Didn't expect query %v", i, q)
t.Errorf("(%d) Didn't expect query \n%v", i, q)
}
}
}
@@ -371,7 +403,7 @@ func TestProviderParseAndExec(t *testing.T) {
{
"",
false,
`{"page":1,"perPage":123,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":123,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
},
// invalid query
{
@@ -407,7 +439,7 @@ func TestProviderParseAndExec(t *testing.T) {
{
"page=3&perPage=555&filter=test1>1&sort=-test2,test3",
false,
`{"page":1,"perPage":200,"totalItems":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":200,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
},
}
+6 -4
View File
@@ -1,5 +1,7 @@
# all environments should start with 'PB_' prefix
PB_BACKEND_URL = /
PB_PROFILE_COLLECTION = profiles
PB_RULES_SYNTAX_DOCS = https://pocketbase.io/docs/manage-collections#rules-filters-syntax
PB_INSTALLER_PARAM = installer
PB_BACKEND_URL = "../"
PB_PROFILE_COLLECTION = "profiles"
PB_INSTALLER_PARAM = "installer"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/manage-collections#rules-filters-syntax"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.4.2"
+1 -1
View File
@@ -1 +1 @@
PB_BACKEND_URL = http://localhost:8090
PB_BACKEND_URL = "http://127.0.0.1:8090"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More