mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-30 11:31:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d4fc04c1 | ||
|
|
a7b29c1961 | ||
|
|
147344546b | ||
|
|
ff935a39a1 | ||
|
|
2cce0b17b0 | ||
|
|
5f25572e95 | ||
|
|
dd5c9ccce8 | ||
|
|
da29141248 | ||
|
|
7f6049ebd6 | ||
|
|
119e1fb3f2 | ||
|
|
c5091898ae | ||
|
|
35bd395e55 | ||
|
|
d56b8fcb90 | ||
|
|
65b830198b | ||
|
|
ac0c23ff64 | ||
|
|
a355c8e8a9 | ||
|
|
c346dd69a4 | ||
|
|
f8f785d6e3 | ||
|
|
8288da8372 | ||
|
|
8b2b26c196 | ||
|
|
8009d37d24 | ||
|
|
6e9d000426 | ||
|
|
a426484916 | ||
|
|
b0ca9b2f1b | ||
|
|
956263d1fc | ||
|
|
51d635bc12 | ||
|
|
5fb45a1864 | ||
|
|
4e58e7ad6a | ||
|
|
93ab5fbea2 | ||
|
|
f459dd8812 | ||
|
|
95f9d685dc | ||
|
|
bb00f198bc | ||
|
|
0e1b9a3897 | ||
|
|
e32cf12908 | ||
|
|
c152f99793 | ||
|
|
f8b7a40837 | ||
|
|
a049a37624 | ||
|
|
8268c26d8b | ||
|
|
6292e6cc2e | ||
|
|
fbeaabf6aa | ||
|
|
30d1b9f358 | ||
|
|
16fa099685 | ||
|
|
bb4bebc724 | ||
|
|
9d0ea7635b | ||
|
|
d35134e913 | ||
|
|
ce8af46fff | ||
|
|
87ecb1114c | ||
|
|
c070be2c47 | ||
|
|
4f0041a128 | ||
|
|
0ac24af7c9 | ||
|
|
96b2c5fedf | ||
|
|
4019ca5f00 | ||
|
|
20fe3c8c91 | ||
|
|
d87a5e544c | ||
|
|
bb527be493 | ||
|
|
9e3b230c8e | ||
|
|
686198a22e | ||
|
|
086b992c7d | ||
|
|
88d8cec3d9 | ||
|
|
7926501649 | ||
|
|
4c2cd5a534 | ||
|
|
5d8fc939e2 | ||
|
|
1a5180d7d3 | ||
|
|
8a08a4764d | ||
|
|
66b317f01c | ||
|
|
ab5a770346 | ||
|
|
65697add43 | ||
|
|
841415f0ff | ||
|
|
f295ce9403 | ||
|
|
383b2a1279 | ||
|
|
73fb12c2bc | ||
|
|
571c4dcc8d | ||
|
|
f56adf26f4 | ||
|
|
f8f3ca25ee | ||
|
|
e01f76d37b | ||
|
|
47fc9b1066 | ||
|
|
eaf08a5c15 | ||
|
|
8ef3d4e966 | ||
|
|
04e0cec32c | ||
|
|
9a231ba7b3 | ||
|
|
7f959011b8 | ||
|
|
994761b728 | ||
|
|
f19b9e3552 | ||
|
|
36783b8f04 | ||
|
|
a076cc906f | ||
|
|
b1a30f4050 | ||
|
|
4506fb17e9 | ||
|
|
72f72bc84f | ||
|
|
7fd5102fb5 | ||
|
|
d6bdc51009 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
var _ Model = (*Record)(nil)
|
||||
var _ ColumnValueMapper = (*Record)(nil)
|
||||
var _ FilesManager = (*Record)(nil)
|
||||
|
||||
type Record struct {
|
||||
BaseModel
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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",
|
||||
|
||||
@@ -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,11 @@ func (s *System) DeletePrefix(prefix string) []error {
|
||||
return failed
|
||||
}
|
||||
|
||||
var inlineServeContentTypes = []string{
|
||||
"image/png", "image/jpg", "image/jpeg", "image/gif",
|
||||
"video/mp4", "video/3gpp", "video/quicktime", "video/x-ms-wmv",
|
||||
}
|
||||
|
||||
// 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 +190,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
`
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {:") +
|
||||
".+" +
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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":""}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.1"
|
||||
+1
-1
@@ -1 +1 @@
|
||||
PB_BACKEND_URL = http://localhost:8090
|
||||
PB_BACKEND_URL = "http://127.0.0.1:8090"
|
||||
@@ -0,0 +1,12 @@
|
||||
import{S as ka,i as Sa,s as va,e as Ca,f as Aa,g as Ma,y as Sn,o as Da,G as Oa,H as Ta,I as Ba,J as La,K as Ra,C as vn,L as Pa}from"./index.0d37b1b7.js";class z{constructor(){}lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){let s=[];return this.decompose(0,e,s,2),i.length&&i.decompose(0,i.length,s,3),this.decompose(t,this.length,s,1),Ve.from(s,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){let i=[];return this.decompose(e,t,i,0),Ve.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),s=new Zt(this),r=new Zt(e);for(let o=t,l=t;;){if(s.next(o),r.next(o),o=0,s.lineBreak!=r.lineBreak||s.done!=r.done||s.value!=r.value)return!1;if(l+=s.value.length,s.done||l>=i)return!0}}iter(e=1){return new Zt(this,e)}iterRange(e,t=this.length){return new zo(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{t==null&&(t=this.lines+1);let s=this.line(e).from;i=this.iterRange(s,Math.max(s,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new qo(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?z.empty:e.length<=32?new Q(e):Ve.from(Q.split(e,[]))}}class Q extends z{constructor(e,t=Ea(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.text[r],l=s+o.length;if((t?i:l)>=e)return new Ia(s,l,i,o);s=l+1,i++}}decompose(e,t,i,s){let r=e<=0&&t>=this.length?this:new Q(ar(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(s&1){let o=i.pop(),l=Pi(r.text,o.text.slice(),0,r.length);if(l.length<=32)i.push(new Q(l,o.length+r.length));else{let h=l.length>>1;i.push(new Q(l.slice(0,h)),new Q(l.slice(h)))}}else i.push(r)}replace(e,t,i){if(!(i instanceof Q))return super.replace(e,t,i);let s=Pi(this.text,Pi(i.text,ar(this.text,0,e)),t),r=this.length+i.length-(t-e);return s.length<=32?new Q(s,r):Ve.from(Q.split(s,[]),r)}sliceString(e,t=this.length,i=`
|
||||
`){let s="";for(let r=0,o=0;r<=t&&o<this.text.length;o++){let l=this.text[o],h=r+l.length;r>e&&o&&(s+=i),e<h&&t>r&&(s+=l.slice(Math.max(0,e-r),t-r)),r=h+1}return s}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],s=-1;for(let r of e)i.push(r),s+=r.length+1,i.length==32&&(t.push(new Q(i,s)),i=[],s=-1);return s>-1&&t.push(new Q(i,s)),t}}class Ve extends z{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.children[r],l=s+o.length,h=i+o.lines-1;if((t?h:l)>=e)return o.lineInner(e,t,i,s);s=l+1,i=h+1}}decompose(e,t,i,s){for(let r=0,o=0;o<=t&&r<this.children.length;r++){let l=this.children[r],h=o+l.length;if(e<=h&&t>=o){let a=s&((o<=e?1:0)|(h>=t?2:0));o>=e&&h<=t&&!a?i.push(l):l.decompose(e-o,t-o,i,a)}o=h+1}}replace(e,t,i){if(i.lines<this.lines)for(let s=0,r=0;s<this.children.length;s++){let o=this.children[s],l=r+o.length;if(e>=r&&t<=l){let h=o.replace(e-r,t-r,i),a=this.lines-o.lines+h.lines;if(h.lines<a>>5-1&&h.lines>a>>5+1){let c=this.children.slice();return c[s]=h,new Ve(c,this.length-(t-e)+i.length)}return super.replace(r,l,h)}r=l+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=`
|
||||
`){let s="";for(let r=0,o=0;r<this.children.length&&o<=t;r++){let l=this.children[r],h=o+l.length;o>e&&r&&(s+=i),e<h&&t>o&&(s+=l.sliceString(e-o,t-o,i)),o=h+1}return s}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof Ve))return 0;let i=0,[s,r,o,l]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;s+=t,r+=t){if(s==o||r==l)return i;let h=this.children[s],a=e.children[r];if(h!=a)return i+h.scanIdentical(a,t);i+=h.length+1}}static from(e,t=e.reduce((i,s)=>i+s.length+1,-1)){let i=0;for(let d of e)i+=d.lines;if(i<32){let d=[];for(let p of e)p.flatten(d);return new Q(d,t)}let s=Math.max(32,i>>5),r=s<<1,o=s>>1,l=[],h=0,a=-1,c=[];function f(d){let p;if(d.lines>r&&d instanceof Ve)for(let g of d.children)f(g);else d.lines>o&&(h>o||!h)?(u(),l.push(d)):d instanceof Q&&h&&(p=c[c.length-1])instanceof Q&&d.lines+p.lines<=32?(h+=d.lines,a+=d.length+1,c[c.length-1]=new Q(p.text.concat(d.text),p.length+1+d.length)):(h+d.lines>s&&u(),h+=d.lines,a+=d.length+1,c.push(d))}function u(){h!=0&&(l.push(c.length==1?c[0]:Ve.from(c,a)),a=-1,h=c.length=0)}for(let d of e)f(d);return u(),l.length==1?l[0]:new Ve(l,t)}}z.empty=new Q([""],0);function Ea(n){let e=-1;for(let t of n)e+=t.length+1;return e}function Pi(n,e,t=0,i=1e9){for(let s=0,r=0,o=!0;r<n.length&&s<=i;r++){let l=n[r],h=s+l.length;h>=t&&(h>i&&(l=l.slice(0,i-s)),s<t&&(l=l.slice(t-s)),o?(e[e.length-1]+=l,o=!1):e.push(l)),s=h+1}return e}function ar(n,e,t){return Pi(n,[""],e,t)}class Zt{constructor(e,t=1){this.dir=t,this.done=!1,this.lineBreak=!1,this.value="",this.nodes=[e],this.offsets=[t>0?1:(e instanceof Q?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let i=this.nodes.length-1,s=this.nodes[i],r=this.offsets[i],o=r>>1,l=s instanceof Q?s.text.length:s.children.length;if(o==(t>0?l:0)){if(i==0)return this.done=!0,this.value="",this;t>0&&this.offsets[i-1]++,this.nodes.pop(),this.offsets.pop()}else if((r&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=`
|
||||
`,this;e--}else if(s instanceof Q){let h=s.text[o+(t<0?-1:0)];if(this.offsets[i]+=t,h.length>Math.max(0,e))return this.value=e==0?h:t>0?h.slice(e):h.slice(0,h.length-e),this;e-=h.length}else{let h=s.children[o+(t<0?-1:0)];e>h.length?(e-=h.length,this.offsets[i]+=t):(t<0&&this.offsets[i]--,this.nodes.push(h),this.offsets.push(t>0?1:(h instanceof Q?h.text.length:h.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}}class zo{constructor(e,t,i){this.value="",this.done=!1,this.cursor=new Zt(e,t>i?-1:1),this.pos=t>i?e.length:0,this.from=Math.min(t,i),this.to=Math.max(t,i)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let i=t<0?this.pos-this.from:this.to-this.pos;e>i&&(e=i),i-=e;let{value:s}=this.cursor.next(e);return this.pos+=(s.length+e)*t,this.value=s.length<=i?s:t<0?s.slice(s.length-i):s.slice(0,i),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}}class qo{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:i,value:s}=this.inner.next(e);return t?(this.done=!0,this.value=""):i?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=s,this.afterBreak=!1),this}get lineBreak(){return!1}}typeof Symbol<"u"&&(z.prototype[Symbol.iterator]=function(){return this.iter()},Zt.prototype[Symbol.iterator]=zo.prototype[Symbol.iterator]=qo.prototype[Symbol.iterator]=function(){return this});class Ia{constructor(e,t,i,s){this.from=e,this.to=t,this.number=i,this.text=s}get length(){return this.to-this.from}}let Dt="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(n=>n?parseInt(n,36):1);for(let n=1;n<Dt.length;n++)Dt[n]+=Dt[n-1];function Na(n){for(let e=1;e<Dt.length;e+=2)if(Dt[e]>n)return Dt[e-1]<=n;return!1}function cr(n){return n>=127462&&n<=127487}const fr=8205;function Ae(n,e,t=!0,i=!0){return(t?Ko:Va)(n,e,i)}function Ko(n,e,t){if(e==n.length)return e;e&&Uo(n.charCodeAt(e))&&jo(n.charCodeAt(e-1))&&e--;let i=re(n,e);for(e+=ve(i);e<n.length;){let s=re(n,e);if(i==fr||s==fr||t&&Na(s))e+=ve(s),i=s;else if(cr(s)){let r=0,o=e-2;for(;o>=0&&cr(re(n,o));)r++,o-=2;if(r%2==0)break;e+=2}else break}return e}function Va(n,e,t){for(;e>0;){let i=Ko(n,e-2,t);if(i<e)return i;e--}return 0}function Uo(n){return n>=56320&&n<57344}function jo(n){return n>=55296&&n<56320}function re(n,e){let t=n.charCodeAt(e);if(!jo(t)||e+1==n.length)return t;let i=n.charCodeAt(e+1);return Uo(i)?(t-55296<<10)+(i-56320)+65536:t}function Rs(n){return n<=65535?String.fromCharCode(n):(n-=65536,String.fromCharCode((n>>10)+55296,(n&1023)+56320))}function ve(n){return n<65536?1:2}const jn=/\r\n?|\n/;var me=function(n){return n[n.Simple=0]="Simple",n[n.TrackDel=1]="TrackDel",n[n.TrackBefore=2]="TrackBefore",n[n.TrackAfter=3]="TrackAfter",n}(me||(me={}));class He{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;t<this.sections.length;t+=2)e+=this.sections[t];return e}get newLength(){let e=0;for(let t=0;t<this.sections.length;t+=2){let i=this.sections[t+1];e+=i<0?this.sections[t]:i}return e}get empty(){return this.sections.length==0||this.sections.length==2&&this.sections[1]<0}iterGaps(e){for(let t=0,i=0,s=0;t<this.sections.length;){let r=this.sections[t++],o=this.sections[t++];o<0?(e(i,s,r),s+=r):s+=o,i+=r}}iterChangedRanges(e,t=!1){Gn(this,e,t)}get invertedDesc(){let e=[];for(let t=0;t<this.sections.length;){let i=this.sections[t++],s=this.sections[t++];s<0?e.push(i,s):e.push(s
Line truncated
|
||||
`}get readOnly(){return this.facet(tl)}phrase(e,...t){for(let i of this.facet(V.phrases))if(Object.prototype.hasOwnProperty.call(i,e)){e=i[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(i,s)=>{if(s=="$")return"$";let r=+(s||1);return!r||r>t.length?i:t[r-1]})),e}languageDataAt(e,t,i=-1){let s=[];for(let r of this.facet(_o))for(let o of r(this,t,i))Object.prototype.hasOwnProperty.call(o,e)&&s.push(o[e]);return s}charCategorizer(e){return $a(this.languageDataAt("wordChars",e).join(""))}wordAt(e){let{text:t,from:i,length:s}=this.doc.lineAt(e),r=this.charCategorizer(e),o=e-i,l=e-i;for(;o>0;){let h=Ae(t,o,!1);if(r(t.slice(h,o))!=ce.Word)break;o=h}for(;l<s;){let h=Ae(t,l);if(r(t.slice(l,h))!=ce.Word)break;l=h}return o==l?null:m.range(o+i,l+i)}}V.allowMultipleSelections=Xo;V.tabSize=T.define({combine:n=>n.length?n[0]:4});V.lineSeparator=Yo;V.readOnly=tl;V.phrases=T.define({compare(n,e){let t=Object.keys(n),i=Object.keys(e);return t.length==i.length&&t.every(s=>n[s]==e[s])}});V.languageData=_o;V.changeFilter=Qo;V.transactionFilter=Zo;V.transactionExtender=el;Qe.reconfigure=W.define();function zt(n,e,t={}){let i={};for(let s of n)for(let r of Object.keys(s)){let o=s[r],l=i[r];if(l===void 0)i[r]=o;else if(!(l===o||o===void 0))if(Object.hasOwnProperty.call(t,r))i[r]=t[r](l,o);else throw new Error("Config merge conflict for field "+r)}for(let s in e)i[s]===void 0&&(i[s]=e[s]);return i}class gt{eq(e){return this==e}range(e,t=e){return ri.create(e,t,this)}}gt.prototype.startSide=gt.prototype.endSide=0;gt.prototype.point=!1;gt.prototype.mapMode=me.TrackDel;class ri{constructor(e,t,i){this.from=e,this.to=t,this.value=i}static create(e,t,i){return new ri(e,t,i)}}function Qn(n,e){return n.from-e.from||n.value.startSide-e.value.startSide}class Is{constructor(e,t,i,s){this.from=e,this.to=t,this.value=i,this.maxPoint=s}get length(){return this.to[this.to.length-1]}findIndex(e,t,i,s=0){let r=i?this.to:this.from;for(let o=s,l=r.length;;){if(o==l)return o;let h=o+l>>1,a=r[h]-e||(i?this.value[h].endSide:this.value[h].startSide)-t;if(h==o)return a>=0?o:l;a>=0?l=h:o=h+1}}between(e,t,i,s){for(let r=this.findIndex(t,-1e9,!0),o=this.findIndex(i,1e9,!1,r);r<o;r++)if(s(this.from[r]+e,this.to[r]+e,this.value[r])===!1)return!1}map(e,t){let i=[],s=[],r=[],o=-1,l=-1;for(let h=0;h<this.value.length;h++){let a=this.value[h],c=this.from[h]+e,f=this.to[h]+e,u,d;if(c==f){let p=t.mapPos(c,a.startSide,a.mapMode);if(p==null||(u=d=p,a.startSide!=a.endSide&&(d=t.mapPos(c,a.endSide),d<u)))continue}else if(u=t.mapPos(c,a.startSide),d=t.mapPos(f,a.endSide),u>d||u==d&&a.startSide>0&&a.endSide<=0)continue;(d-u||a.endSide-a.startSide)<0||(o<0&&(o=u),a.point&&(l=Math.max(l,d-u)),i.push(a),s.push(u-o),r.push(d-o))}return{mapped:i.length?new Is(s,r,i,l):null,pos:o}}}class X{constructor(e,t,i,s){this.chunkPos=e,this.chunk=t,this.nextLayer=i,this.maxPoint=s}static create(e,t,i,s){return new X(e,t,i,s)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:i=!1,filterFrom:s=0,filterTo:r=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(i&&(t=t.slice().sort(Qn)),this.isEmpty)return t.length?X.of(t):this;let l=new sl(this,null,-1).goto(0),h=0,a=[],c=new mt;for(;l.value||h<t.length;)if(h<t.length&&(l.from-t[h].from||l.startSide-t[h].value.startSide)>=0){let f=t[h++];c.addInner(f.from,f.to,f.value)||a.push(f)}else l.rangeIndex==1&&l.chunkIndex<this.chunk.length&&(h==t.length||this.chunkEnd(l.chunkIndex)<t[h].from)&&(!o||s>this.chunkEnd(l.chunkIndex)||r<this.chunkPos[l.chunkIndex])&&c.addChunk(this.chunkPos[l.chunkIndex],this.chunk[l.chunkIndex])?l.nextChunk():((!o||s>l.to||r<l.from||o(l.from,l.to,l.value))&&(c.addInner(l.from,l.to,l.value)||a.push(ri.create(l.from,l.to,l.value))),l.next());return c.finishInner(this.nextLayer.isEmpty&&!a.length?X.empty:this.nextLayer.update({add:a,filter:o,filterFrom:s,filterTo:r}))}map(e){if(e.empty||this.isEmpty)return this;let t=[],i=[],s=-1;for(let o=0;o<this.chunk.length;o++){let l=this.chunkPos[o],h=this.chunk[o],a=e.touchesRange(l,l+h.length);if(a===!1)s=Math.max(s,h.maxPoint),t.push(h),i.push(e.mapPos(l));else if(a===!0){let{mapped:c,pos:f}=h.map(l,e);c&&(s=Math.max(s,c.maxPoint),t.push(c),i.push(f))}}let r=this.nextLayer.map(e);return t.length==0?r:new X(i,t,r||X.empty,s)}between(e,t,i){if(!this.isEmpty){for(let s=0;s<this.chunk.length;s++){let r=this.chunkPos[s],o=this.chunk[s];if(t>=r&&e<=r+o.length&&o.between(r,e-r,t-r,i)===!1)return}this.nextLayer.between(e,t,i)}}iter(e=0){return oi.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return oi.from(e).goto(t)}static compare(e,t,i,s,r=-1){let o=e.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),l=t.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),h=pr(o,l,i),a=new Ut(o,h,r),c=new Ut(l
Line truncated
|
||||
`)}static newName(){let e=br[yr]||1;return br[yr]=e+1,ts+e.toString(36)}static mount(e,t){(e[is]||new Xa(e)).mount(Array.isArray(t)?t:[t])}}let wi=null;class Xa{constructor(e){if(!e.head&&e.adoptedStyleSheets&&typeof CSSStyleSheet<"u"){if(wi)return e.adoptedStyleSheets=[wi.sheet].concat(e.adoptedStyleSheets),e[is]=wi;this.sheet=new CSSStyleSheet,e.adoptedStyleSheets=[this.sheet].concat(e.adoptedStyleSheets),wi=this}else{this.styleTag=(e.ownerDocument||e).createElement("style");let t=e.head||e;t.insertBefore(this.styleTag,t.firstChild)}this.modules=[],e[is]=this}mount(e){let t=this.sheet,i=0,s=0;for(let r=0;r<e.length;r++){let o=e[r],l=this.modules.indexOf(o);if(l<s&&l>-1&&(this.modules.splice(l,1),s--,l=-1),l==-1){if(this.modules.splice(s++,0,o),t)for(let h=0;h<o.rules.length;h++)t.insertRule(o.rules[h],i++)}else{for(;s<l;)i+=this.modules[s++].rules.length;i+=o.rules.length,s++}}if(!t){let r="";for(let o=0;o<this.modules.length;o++)r+=this.modules[o].getRules()+`
|
||||
`;this.styleTag.textContent=r}}}var ot={8:"Backspace",9:"Tab",10:"Enter",12:"NumLock",13:"Enter",16:"Shift",17:"Control",18:"Alt",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",44:"PrintScreen",45:"Insert",46:"Delete",59:";",61:"=",91:"Meta",92:"Meta",106:"*",107:"+",108:",",109:"-",110:".",111:"/",144:"NumLock",145:"ScrollLock",160:"Shift",161:"Shift",162:"Control",163:"Control",164:"Alt",165:"Alt",173:"-",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},Lt={48:")",49:"!",50:"@",51:"#",52:"$",53:"%",54:"^",55:"&",56:"*",57:"(",59:":",61:"+",173:"_",186:":",187:"+",188:"<",189:"_",190:">",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},wr=typeof navigator<"u"&&/Chrome\/(\d+)/.exec(navigator.userAgent);typeof navigator<"u"&&/Gecko\/\d+/.test(navigator.userAgent);var Ya=typeof navigator<"u"&&/Mac/.test(navigator.platform),Qa=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent),Za=Ya||wr&&+wr[1]<57;for(var oe=0;oe<10;oe++)ot[48+oe]=ot[96+oe]=String(oe);for(var oe=1;oe<=24;oe++)ot[oe+111]="F"+oe;for(var oe=65;oe<=90;oe++)ot[oe]=String.fromCharCode(oe+32),Lt[oe]=String.fromCharCode(oe);for(var An in ot)Lt.hasOwnProperty(An)||(Lt[An]=ot[An]);function ec(n){var e=Za&&(n.ctrlKey||n.altKey||n.metaKey)||Qa&&n.shiftKey&&n.key&&n.key.length==1||n.key=="Unidentified",t=!e&&n.key||(n.shiftKey?Lt:ot)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function zi(n){let e;return n.nodeType==11?e=n.getSelection?n:n.ownerDocument:e=n,e.getSelection()}function Rt(n,e){return e?n==e||n.contains(e.nodeType!=1?e.parentNode:e):!1}function tc(){let n=document.activeElement;for(;n&&n.shadowRoot;)n=n.shadowRoot.activeElement;return n}function Ii(n,e){if(!e.anchorNode)return!1;try{return Rt(n,e.anchorNode)}catch{return!1}}function li(n){return n.nodeType==3?Pt(n,0,n.nodeValue.length).getClientRects():n.nodeType==1?n.getClientRects():[]}function qi(n,e,t,i){return t?xr(n,e,t,i,-1)||xr(n,e,t,i,1):!1}function Ki(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e}function xr(n,e,t,i,s){for(;;){if(n==t&&e==i)return!0;if(e==(s<0?0:hi(n))){if(n.nodeName=="DIV")return!1;let r=n.parentNode;if(!r||r.nodeType!=1)return!1;e=Ki(n)+(s<0?0:1),n=r}else if(n.nodeType==1){if(n=n.childNodes[e+(s<0?-1:0)],n.nodeType==1&&n.contentEditable=="false")return!1;e=s<0?hi(n):0}else return!1}}function hi(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}const rl={left:0,right:0,top:0,bottom:0};function hn(n,e){let t=e?n.left:n.right;return{left:t,right:t,top:n.top,bottom:n.bottom}}function ic(n){return{left:0,right:n.innerWidth,top:0,bottom:n.innerHeight}}function nc(n,e,t,i,s,r,o,l){let h=n.ownerDocument,a=h.defaultView;for(let c=n;c;)if(c.nodeType==1){let f,u=c==h.body;if(u)f=ic(a);else{if(c.scrollHeight<=c.clientHeight&&c.scrollWidth<=c.clientWidth){c=c.parentNode;continue}let g=c.getBoundingClientRect();f={left:g.left,right:g.left+c.clientWidth,top:g.top,bottom:g.top+c.clientHeight}}let d=0,p=0;if(s=="nearest")e.top<f.top?(p=-(f.top-e.top+o),t>0&&e.bottom>f.bottom+p&&(p=e.bottom-f.bottom+p+o)):e.bottom>f.bottom&&(p=e.bottom-f.bottom+o,t<0&&e.top-p<f.top&&(p=-(f.top+p-e.top+o)));else{let g=e.bottom-e.top,y=f.bottom-f.top;p=(s=="center"&&g<=y?e.top+g/2-y/2:s=="start"||s=="center"&&t<0?e.top-o:e.bottom-y+o)-f.top}if(i=="nearest"?e.left<f.left?(d=-(f.left-e.left+r),t>0&&e.right>f.right+d&&(d=e.right-f.right+d+r)):e.right>f.right&&(d=e.right-f.right+r,t<0&&e.left<f.left+d&&(d=-(f.left+d-e.left+r))):d=(i=="center"?e.left+(e.right-e.left)/2-(f.right-f.left)/2:i=="start"==l?e.left-r:e.right-(f.right-f.left)+r)-f.left,d||p)if(u)a.scrollBy(d,p);else{if(p){let g=c.scrollTop;c.scrollTop+=p,p=c.scrollTop-g}if(d){let g=c.scrollLeft;c.scrollLeft+=d,d=c.scrollLeft-g}e={left:e.left-d,top:e.top-p,right:e.right-d,bottom:e.bottom-p}}if(u)break;c=c.assignedSlot||c.parentNode,i=s="nearest"}else if(c.nodeType==11)c=c.host;else break}class sc{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){this.set(e.anchorNode,e.anchorOffset,e.focusNode,e.focusOffset)}set(e,t,i,s){this.anchorNode=e,this.anchorOffset=t,this.focusNode=i,this.focusOffset=s}}let Ct=null;function ol(n){if(n.setActive)return n.setActive();if(Ct)return n.focus(Ct);let e=[];for(let t=n;t&&(e.push(t,t.scrollTop,t.scrollLeft),t!=t.ownerDocument);t=t.parentNode);if(n.focus(Ct==null?{get preventScroll(){return Ct={preventScroll:!0},!0}}:void 0),!Ct){Ct=!1;for(let t=0;t<e.length;){let i=e[t++],s=e[t++],r=e[t++];i.scrollTop!=s&&(i.scrollTop=s),i.scrollLeft!=r&&(i.scrollLeft=r)}}}let kr;function Pt(n,e,tLine truncated
|
||||
`,s=n.state.doc.line(s.number+(t?1:-1)),r=n.bidiSpans(s),a=m.cursor(t?s.from:s.to)}if(h){if(!h(c))return l}else{if(!i)return a;h=i(c)}l=a}}function Lc(n,e,t){let i=n.state.charCategorizer(e),s=i(t);return r=>{let o=i(r);return s==ce.Space&&(s=o),s==o}}function Rc(n,e,t,i){let s=e.head,r=t?1:-1;if(s==(t?n.state.doc.length:0))return m.cursor(s,e.assoc);let o=e.goalColumn,l,h=n.contentDOM.getBoundingClientRect(),a=n.coordsAtPos(s),c=n.documentTop;if(a)o==null&&(o=a.left-h.left),l=r<0?a.top:a.bottom;else{let d=n.viewState.lineBlockAt(s);o==null&&(o=Math.min(h.right-h.left,n.defaultCharacterWidth*(s-d.from))),l=(r<0?d.top:d.bottom)+c}let f=h.left+o,u=i!=null?i:n.defaultLineHeight>>1;for(let d=0;;d+=10){let p=l+(u+d)*r,g=Il(n,{x:f,y:p},!1,r);if(p<h.top||p>h.bottom||(r<0?g<s:g>s))return m.cursor(g,e.assoc,void 0,o)}}function Tn(n,e,t){let i=n.state.facet(Dl).map(s=>s(n));for(;;){let s=!1;for(let r of i)r.between(t.from-1,t.from+1,(o,l,h)=>{t.from>o&&t.from<l&&(t=e.from>t.from?m.cursor(o,1):m.cursor(l,-1),s=!0)});if(!s)return t}}class Pc{constructor(e){this.lastKeyCode=0,this.lastKeyTime=0,this.lastTouchTime=0,this.lastFocusTime=0,this.lastScrollTop=0,this.lastScrollLeft=0,this.chromeScrollHack=-1,this.pendingIOSKey=void 0,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastEscPress=0,this.lastContextMenu=0,this.scrollHandlers=[],this.registeredEvents=[],this.customHandlers=[],this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.rapidCompositionStart=!1,this.mouseSelection=null;for(let t in ie){let i=ie[t];e.contentDOM.addEventListener(t,s=>{!Vr(e,s)||this.ignoreDuringComposition(s)||t=="keydown"&&this.keydown(e,s)||(this.mustFlushObserver(s)&&e.observer.forceFlush(),this.runCustomHandlers(t,e,s)?s.preventDefault():i(e,s))},gs[t]),this.registeredEvents.push(t)}M.chrome&&M.chrome_version==102&&e.scrollDOM.addEventListener("wheel",()=>{this.chromeScrollHack<0?e.contentDOM.style.pointerEvents="none":window.clearTimeout(this.chromeScrollHack),this.chromeScrollHack=setTimeout(()=>{this.chromeScrollHack=-1,e.contentDOM.style.pointerEvents=""},100)},{passive:!0}),this.notifiedFocused=e.hasFocus,M.safari&&e.contentDOM.addEventListener("input",()=>null)}setSelectionOrigin(e){this.lastSelectionOrigin=e,this.lastSelectionTime=Date.now()}ensureHandlers(e,t){var i;let s;this.customHandlers=[];for(let r of t)if(s=(i=r.update(e).spec)===null||i===void 0?void 0:i.domEventHandlers){this.customHandlers.push({plugin:r.value,handlers:s});for(let o in s)this.registeredEvents.indexOf(o)<0&&o!="scroll"&&(this.registeredEvents.push(o),e.contentDOM.addEventListener(o,l=>{!Vr(e,l)||this.runCustomHandlers(o,e,l)&&l.preventDefault()}))}}runCustomHandlers(e,t,i){for(let s of this.customHandlers){let r=s.handlers[e];if(r)try{if(r.call(s.plugin,i,t)||i.defaultPrevented)return!0}catch(o){Le(t.state,o)}}return!1}runScrollHandlers(e,t){this.lastScrollTop=e.scrollDOM.scrollTop,this.lastScrollLeft=e.scrollDOM.scrollLeft;for(let i of this.customHandlers){let s=i.handlers.scroll;if(s)try{s.call(i.plugin,t,e)}catch(r){Le(e.state,r)}}}keydown(e,t){if(this.lastKeyCode=t.keyCode,this.lastKeyTime=Date.now(),t.keyCode==9&&Date.now()<this.lastEscPress+2e3)return!0;if(M.android&&M.chrome&&!t.synthetic&&(t.keyCode==13||t.keyCode==8))return e.observer.delayAndroidKey(t.key,t.keyCode),!0;let i;return M.ios&&(i=Nl.find(s=>s.keyCode==t.keyCode))&&!(t.ctrlKey||t.altKey||t.metaKey)&&!t.synthetic?(this.pendingIOSKey=i,setTimeout(()=>this.flushIOSKey(e),250),!0):!1}flushIOSKey(e){let t=this.pendingIOSKey;return t?(this.pendingIOSKey=void 0,ti(e.contentDOM,t.key,t.keyCode)):!1}ignoreDuringComposition(e){return/^key/.test(e.type)?this.composing>0?!0:M.safari&&!M.ios&&Date.now()-this.compositionEndedAt<100?(this.compositionEndedAt=0,!0):!1:!1}mustFlushObserver(e){return e.type=="keydown"&&e.keyCode!=229||e.type=="compositionend"&&!M.ios}startMouseSelection(e){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=e}update(e){this.mouseSelection&&this.mouseSelection.update(e),e.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}}const Nl=[{key:"Backspace",keyCode:8,inputType:"deleteContentBackward"},{key:"Enter",keyCode:13,inputType:"insertParagraph"},{key:"Delete",keyCode:46,inputType:"deleteContentForward"}],Vl=[16,17,18,20,91,92,224,225];class Ec{constructor(e,t,i,s){this.view=e,this.style=i,this.mustSelect=s,this.lastEvent=t;let r=e.contentDOM.ownerDocument;r.addEventListener("mousemove",this.move=this.move.bind(this)),r.addEventListener("mouseup",this.up=this.up.bind(this)),this.extend=t.shiftKey,this.multiple=e.state.facet(V.allowMultipleSelections)&&Ic(e,t),this.dragMove=Nc(e,t),this.dragging=Vc(e,t)&&Ws(t)==1?null:!1,this.dragging===!1&&(t.preventDefault(),this.select(t))}move(e){if(e.buttons==0)return this.destroy();this.dragging===!1&&this.select(this.lastEvent=e)}up(e){this.dragging==null&&this.select(this.lastEvent),this.dragging|
Line truncated
|
||||
-\x7F-\x9F\xAD\u061C\u200B\u200E\u200F\u2028\u2029\u202D\u202E\u2066\u2067\u2069\uFEFF\uFFF9-\uFFFC]`,xs),Tf={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8294:"left-to-right isolate",8295:"right-to-left isolate",8297:"pop directional isolate",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"};let Rn=null;function Bf(){var n;if(Rn==null&&typeof document<"u"&&document.body){let e=document.body.style;Rn=((n=e.tabSize)!==null&&n!==void 0?n:e.MozTabSize)!=null}return Rn||!1}const Vi=T.define({combine(n){let e=zt(n,{render:null,specialChars:Of,addSpecialChars:null});return(e.replaceTabs=!Bf())&&(e.specialChars=new RegExp(" |"+e.specialChars.source,xs)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+"|"+e.addSpecialChars.source,xs)),e}});function Lf(n={}){return[Vi.of(n),Rf()]}let so=null;function Rf(){return so||(so=ue.fromClass(class{constructor(n){this.view=n,this.decorations=L.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(n.state.facet(Vi)),this.decorations=this.decorator.createDeco(n)}makeDecorator(n){return new Df({regexp:n.specialChars,decoration:(e,t,i)=>{let{doc:s}=t.state,r=re(e[0],0);if(r==9){let o=s.lineAt(i),l=t.state.tabSize,h=di(o.text,l,i-o.from);return L.replace({widget:new Nf((l-h%l)*this.view.defaultCharacterWidth)})}return this.decorationCache[r]||(this.decorationCache[r]=L.replace({widget:new If(n,r)}))},boundary:n.replaceTabs?void 0:/[^]/})}update(n){let e=n.state.facet(Vi);n.startState.facet(Vi)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(n.view)):this.decorations=this.decorator.updateDeco(n,this.decorations)}},{decorations:n=>n.decorations}))}const Pf="\u2022";function Ef(n){return n>=32?Pf:n==10?"\u2424":String.fromCharCode(9216+n)}class If extends kt{constructor(e,t){super(),this.options=e,this.code=t}eq(e){return e.code==this.code}toDOM(e){let t=Ef(this.code),i=e.state.phrase("Control character")+" "+(Tf[this.code]||"0x"+this.code.toString(16)),s=this.options.render&&this.options.render(this.code,i,t);if(s)return s;let r=document.createElement("span");return r.textContent=t,r.title=i,r.setAttribute("aria-label",i),r.className="cm-specialChar",r}ignoreEvent(){return!1}}class Nf extends kt{constructor(e){super(),this.width=e}eq(e){return e.width==this.width}toDOM(){let e=document.createElement("span");return e.textContent=" ",e.className="cm-tab",e.style.width=this.width+"px",e}ignoreEvent(){return!1}}class Vf extends kt{constructor(e){super(),this.content=e}toDOM(){let e=document.createElement("span");return e.className="cm-placeholder",e.style.pointerEvents="none",e.appendChild(typeof this.content=="string"?document.createTextNode(this.content):this.content),typeof this.content=="string"?e.setAttribute("aria-label","placeholder "+this.content):e.setAttribute("aria-hidden","true"),e}ignoreEvent(){return!1}}function ro(n){return ue.fromClass(class{constructor(e){this.view=e,this.placeholder=L.set([L.widget({widget:new Vf(n),side:1}).range(0)])}get decorations(){return this.view.state.doc.length?L.none:this.placeholder}},{decorations:e=>e.decorations})}const ks=2e3;function Ff(n,e,t){let i=Math.min(e.line,t.line),s=Math.max(e.line,t.line),r=[];if(e.off>ks||t.off>ks||e.col<0||t.col<0){let o=Math.min(e.off,t.off),l=Math.max(e.off,t.off);for(let h=i;h<=s;h++){let a=n.doc.line(h);a.length<=l&&r.push(m.range(a.from+o,a.to+l))}}else{let o=Math.min(e.col,t.col),l=Math.max(e.col,t.col);for(let h=i;h<=s;h++){let a=n.doc.line(h),c=es(a.text,o,n.tabSize,!0);if(c>-1){let f=es(a.text,l,n.tabSize);r.push(m.range(a.from+c,a.from+f))}}}return r}function Wf(n,e){let t=n.coordsAtPos(n.viewport.from);return t?Math.round(Math.abs((t.left-e)/n.defaultCharacterWidth)):-1}function oo(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1),i=n.state.doc.lineAt(t),s=t-i.from,r=s>ks?-1:s==i.length?Wf(n,e.clientX):di(i.text,n.state.tabSize,t-i.from);return{line:i.number,col:r,off:s}}function Hf(n,e){let t=oo(n,e),i=n.state.selection;return t?{update(s){if(s.docChanged){let r=s.changes.mapPos(s.startState.doc.line(t.line).from),o=s.state.doc.lineAt(r);t={line:o.number,col:t.col,off:Math.min(t.off,o.length)},i=i.map(s.changes)}},get(s,r,o){let l=oo(n,s);if(!l)return i;let h=Ff(n.state,t,l);return h.length?o?m.create(h.concat(i.ranges)):m.create(h):i}}:null}function zf(n){let e=(n==null?void 0:n.eventFilter)||(t=>t.altKey&&t.button==0);return O.mouseSelectionStyle.of((t,i)=>e(i)?Hf(t,i):null)}const Pn="-10000px";class qf{constructor(e,t,i){this.facet=t,this.createTooltipView=i,this.input=e.state.facet(t),this.tooltips=this.input.filter(s=>s),this.tooltipViews=this.tooltips.map(i)}update(e){let t=e.state.facet(this.facet),i=t.filt
Line truncated
|
||||
`&&(t="");else{let i=t.indexOf(`
|
||||
`);i>-1&&(t=t.slice(0,i))}return e+t.length<=this.to?t:t.slice(0,this.to-e)}nextLine(){let e=this.parsedPos,t=this.lineAfter(e),i=e+t.length;for(let s=this.rangeIndex;;){let r=this.ranges[s].to;if(r>=i||(t=t.slice(0,r-(i-t.length)),s++,s==this.ranges.length))break;let o=this.ranges[s].from,l=this.lineAfter(o);t+=l,i=o+l.length}return{line:t,end:i}}skipGapsTo(e,t,i){for(;;){let s=this.ranges[this.rangeIndex].to,r=e+t;if(i>0?s>r:s>=r)break;t+=this.ranges[++this.rangeIndex].from-s}return t}moveRangeIndex(){for(;this.ranges[this.rangeIndex].to<this.parsedPos;)this.rangeIndex++}emitToken(e,t,i,s,r){if(this.ranges.length>1){r=this.skipGapsTo(t,r,1),t+=r;let o=this.chunk.length;r=this.skipGapsTo(i,r,-1),i+=r,s+=this.chunk.length-o}return this.chunk.push(e,t,i,s),r}parseLine(e){let{line:t,end:i}=this.nextLine(),s=0,{streamParser:r}=this.lang,o=new yh(t,e?e.state.tabSize:4,e?wt(e.state):2);if(o.eol())r.blankLine(this.state,o.indentUnit);else for(;!o.eol();){let l=wh(r.token,o,this.state);if(l&&(s=this.emitToken(this.lang.tokenTable.resolve(l),this.parsedPos+o.start,this.parsedPos+o.pos,4,s)),o.start>1e4)break}this.parsedPos=i,this.moveRangeIndex(),this.parsedPos<this.to&&this.parsedPos++}finishChunk(){let e=Y.build({buffer:this.chunk,start:this.chunkStart,length:this.parsedPos-this.chunkStart,nodeSet:Wu,topID:0,maxBufferLength:2048,reused:this.chunkReused});e=new Y(e.type,e.children,e.positions,e.length,[[this.lang.stateAfter,this.lang.streamParser.copyState(this.state)]]),this.chunks.push(e),this.chunkPos.push(this.chunkStart-this.ranges[0].from),this.chunk=[],this.chunkReused=void 0,this.chunkStart=this.parsedPos}finish(){return new Y(this.lang.topNode,this.chunks,this.chunkPos,this.parsedPos-this.ranges[0].from).balance()}}function wh(n,e,t){e.start=e.pos;for(let i=0;i<10;i++){let s=n(e,t);if(e.pos>e.start)return s}throw new Error("Stream parser failed to advance stream.")}const Xs=Object.create(null),fi=[we.none],Wu=new qs(fi),yo=[],xh=Object.create(null);for(let[n,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])xh[n]=Sh(Xs,e);class kh{constructor(e){this.extra=e,this.table=Object.assign(Object.create(null),xh)}resolve(e){return e?this.table[e]||(this.table[e]=Sh(this.extra,e)):0}}const Hu=new kh(Xs);function Hn(n,e){yo.indexOf(n)>-1||(yo.push(n),console.warn(e))}function Sh(n,e){let t=null;for(let r of e.split(".")){let o=n[r]||k[r];o?typeof o=="function"?t?t=o(t):Hn(r,`Modifier ${r} used at start of tag`):t?Hn(r,`Tag ${r} used as modifier`):t=o:Hn(r,`Unknown highlighting tag ${r}`)}if(!t)return 0;let i=e.replace(/ /g,"_"),s=we.define({id:fi.length,name:i,props:[ou({[i]:t})]});return fi.push(s),s.id}function zu(n){let e=we.define({id:fi.length,name:"Document",props:[ci.add(()=>n)]});return fi.push(e),e}const qu=n=>{let e=Qs(n.state);return e.line?Ku(n):e.block?ju(n):!1};function Ys(n,e){return({state:t,dispatch:i})=>{if(t.readOnly)return!1;let s=n(e,t);return s?(i(t.update(s)),!0):!1}}const Ku=Ys($u,0),Uu=Ys(vh,0),ju=Ys((n,e)=>vh(n,e,Ju(e)),0);function Qs(n,e=n.selection.main.head){let t=n.languageDataAt("commentTokens",e);return t.length?t[0]:{}}const Jt=50;function Gu(n,{open:e,close:t},i,s){let r=n.sliceDoc(i-Jt,i),o=n.sliceDoc(s,s+Jt),l=/\s*$/.exec(r)[0].length,h=/^\s*/.exec(o)[0].length,a=r.length-l;if(r.slice(a-e.length,a)==e&&o.slice(h,h+t.length)==t)return{open:{pos:i-l,margin:l&&1},close:{pos:s+h,margin:h&&1}};let c,f;s-i<=2*Jt?c=f=n.sliceDoc(i,s):(c=n.sliceDoc(i,i+Jt),f=n.sliceDoc(s-Jt,s));let u=/^\s*/.exec(c)[0].length,d=/\s*$/.exec(f)[0].length,p=f.length-d-t.length;return c.slice(u,u+e.length)==e&&f.slice(p,p+t.length)==t?{open:{pos:i+u+e.length,margin:/\s/.test(c.charAt(u+e.length))?1:0},close:{pos:s-d-t.length,margin:/\s/.test(f.charAt(p-1))?1:0}}:null}function Ju(n){let e=[];for(let t of n.selection.ranges){let i=n.doc.lineAt(t.from),s=t.to<=i.to?i:n.doc.lineAt(t.to),r=e.length-1;r>=0&&e[r].to>i.from?e[r].to=s.to:e.push({from:i.from,to:s.to})}return e}function vh(n,e,t=e.selection.ranges){let i=t.map(r=>Qs(e,r.from).block);if(!i.every(r=>r))return null;let s=t.map((r,o)=>Gu(e,i[o],r.from,r.to));if(n!=2&&!s.every(r=>r))return{changes:e.changes(t.map((r,o)=>s[o]?[]:[{from:r.from,insert:i[o].open+" "},{from:r.to,insert:" "+i[o].close}]))};if(n!=1&&s.some(r=>r)){let r=[];for(let o=0,l;o<s.length;o++)if(l=s[o]){let h=i[o],{open:a,close:c}=l;r.push({from:a.pos-h.open.length,to:a.pos+a.margin},{from:c.pos-c.margin,to:c.pos+h.close.length})}return{changes:r}}return null}function $u(n,e,t=e.selection.ranges){let i=[],s=-1;for(let{from:r,to:o}of t){let l=i.length,h=1e9;for(let a=r;a<=o;){let c=e.doc.lineAt(a);if(c.from>s&&(r==o||o>c.from)){s=c.from;let f=Qs(e,a).line;if(!f)continue;let u=/^\s*/.exec(c.text)[0].length,d=u==c
Line truncated
|
||||
`:i=="r"?"\r":i=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp}create(){return this.regexp?new rp(this):new sp(this)}getCursor(e,t=0,i=e.length){return this.regexp?Mt(this,e,t,i):At(this,e,t,i)}}class oa{constructor(e){this.spec=e}}function At(n,e,t,i){return new Wt(e,n.unquoted,t,i,n.caseSensitive?void 0:s=>s.toLowerCase())}class sp extends oa{constructor(e){super(e)}nextMatch(e,t,i){let s=At(this.spec,e,i,e.length).nextOverlapping();return s.done&&(s=At(this.spec,e,0,t).nextOverlapping()),s.done?null:s.value}prevMatchInRange(e,t,i){for(let s=i;;){let r=Math.max(t,s-1e4-this.spec.unquoted.length),o=At(this.spec,e,r,s),l=null;for(;!o.nextOverlapping().done;)l=o.value;if(l)return l;if(r==t)return null;s-=1e4}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.length)}getReplacement(e){return this.spec.replace}matchAll(e,t){let i=At(this.spec,e,0,e.length),s=[];for(;!i.next().done;){if(s.length>=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=At(this.spec,e,Math.max(0,t-this.spec.unquoted.length),Math.min(i+this.spec.unquoted.length,e.length));for(;!r.next().done;)s(r.value.from,r.value.to)}}function Mt(n,e,t,i){return new ia(e,n.search,n.caseSensitive?void 0:{ignoreCase:!0},t,i)}class rp extends oa{nextMatch(e,t,i){let s=Mt(this.spec,e,i,e.length).next();return s.done&&(s=Mt(this.spec,e,0,t).next()),s.done?null:s.value}prevMatchInRange(e,t,i){for(let s=1;;s++){let r=Math.max(t,i-s*1e4),o=Mt(this.spec,e,r,i),l=null;for(;!o.next().done;)l=o.value;if(l&&(r==t||l.from>r+10))return l;if(r==t)return null}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.length)}getReplacement(e){return this.spec.replace.replace(/\$([$&\d+])/g,(t,i)=>i=="$"?"$":i=="&"?e.match[0]:i!="0"&&+i<e.match.length?e.match[i]:t)}matchAll(e,t){let i=Mt(this.spec,e,0,e.length),s=[];for(;!i.next().done;){if(s.length>=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=Mt(this.spec,e,Math.max(0,t-250),Math.min(i+250,e.length));for(;!r.next().done;)s(r.value.from,r.value.to)}}const ui=W.define(),ir=W.define(),nt=ke.define({create(n){return new Kn(Bs(n).create(),null)},update(n,e){for(let t of e.effects)t.is(ui)?n=new Kn(t.value.create(),n.panel):t.is(ir)&&(n=new Kn(n.query,t.value?nr:null));return n},provide:n=>$i.from(n,e=>e.panel)});class Kn{constructor(e,t){this.query=e,this.panel=t}}const op=L.mark({class:"cm-searchMatch"}),lp=L.mark({class:"cm-searchMatch cm-searchMatch-selected"}),hp=ue.fromClass(class{constructor(n){this.view=n,this.decorations=this.highlight(n.state.field(nt))}update(n){let e=n.state.field(nt);(e!=n.startState.field(nt)||n.docChanged||n.selectionSet||n.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:n,panel:e}){if(!e||!n.spec.valid)return L.none;let{view:t}=this,i=new mt;for(let s=0,r=t.visibleRanges,o=r.length;s<o;s++){let{from:l,to:h}=r[s];for(;s<o-1&&h>r[s+1].from-2*250;)h=r[++s].to;n.highlight(t.state.doc,l,h,(a,c)=>{let f=t.state.selection.ranges.some(u=>u.from==a&&u.to==c);i.add(a,c,f?lp:op)})}return i.finish()}},{decorations:n=>n.decorations});function gi(n){return e=>{let t=e.state.field(nt,!1);return t&&t.query.spec.valid?n(e,t):la(e)}}const nn=gi((n,{query:e})=>{let{to:t}=n.state.selection.main,i=e.nextMatch(n.state.doc,t,t);return i?(n.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0,effects:sr(n,i),userEvent:"select.search"}),!0):!1}),sn=gi((n,{query:e})=>{let{state:t}=n,{from:i}=t.selection.main,s=e.prevMatch(t.doc,i,i);return s?(n.dispatch({selection:{anchor:s.from,head:s.to},scrollIntoView:!0,effects:sr(n,s),userEvent:"select.search"}),!0):!1}),ap=gi((n,{query:e})=>{let t=e.matchAll(n.state.doc,1e3);return!t||!t.length?!1:(n.dispatch({selection:m.create(t.map(i=>m.range(i.from,i.to))),userEvent:"select.search.matches"}),!0)}),cp=({state:n,dispatch:e})=>{let t=n.selection;if(t.ranges.length>1||t.main.empty)return!1;let{from:i,to:s}=t.main,r=[],o=0;for(let l=new Wt(n.doc,n.sliceDoc(i,s));!l.next().done;){if(r.length>1e3)return!1;l.value.from==i&&(o=r.length),r.push(m.range(l.value.from,l.value.to))}return e(n.update({selection:m.create(r,o),userEvent:"select.search.matches"})),!0},Ro=gi((n,{query:e})=>{let{state:t}=n,{from:i,to:s}=t.selection.main;if(t.readOnly)return!1;let r=e.nextMatch(t.doc,i,i);if(!r)return!1;let o=[],l,h,a=[];if(r.from==i&&r.to==s&&(h=t.toText(e.getReplacement(r)),o.push({from:r.from,to:r.to,insert:h}),r=e.nextMatch(t.doc,r.from,r.to),a.push(O.announce.of(t.phrase("replaced match on line $",t.doc.lineAt(i).number)+"."))),r){let c=o.length==0||o[0].from>=r.to?0:r.to-r.from-h.length;l={anchor:r.from-c,head:r.to-c},a.push(sr(n,r))}return n.dispatch({changes:o,selection:l,scrollIntoView:!!l,effects:a,userEvent:"input.replace"}),!0}),fp=gi((n,{query:e})=>{if(n.state.readOnly)return!1;let t=e.matchAll(n.state.doc,1e9).map(s=>{let{from:r,to:o}=s;return{from:r,to:o,insert:e.getRep
Line truncated
|
||||
@@ -1,12 +0,0 @@
|
||||
import{S as Sa,i as va,s as Ca,e as Aa,f as Ma,g as Da,y as xn,o as Oa,G as Ta,H as Ba,I as Ra,J as Pa,K as La,C as kn,L as Ea}from"./index.1098a6c6.js";class z{constructor(){}lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){let s=[];return this.decompose(0,e,s,2),i.length&&i.decompose(0,i.length,s,3),this.decompose(t,this.length,s,1),Ve.from(s,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){let i=[];return this.decompose(e,t,i,0),Ve.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),s=new Zt(this),r=new Zt(e);for(let o=t,l=t;;){if(s.next(o),r.next(o),o=0,s.lineBreak!=r.lineBreak||s.done!=r.done||s.value!=r.value)return!1;if(l+=s.value.length,s.done||l>=i)return!0}}iter(e=1){return new Zt(this,e)}iterRange(e,t=this.length){return new zo(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{t==null&&(t=this.lines+1);let s=this.line(e).from;i=this.iterRange(s,Math.max(s,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new qo(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?z.empty:e.length<=32?new Q(e):Ve.from(Q.split(e,[]))}}class Q extends z{constructor(e,t=Ia(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.text[r],l=s+o.length;if((t?i:l)>=e)return new Na(s,l,i,o);s=l+1,i++}}decompose(e,t,i,s){let r=e<=0&&t>=this.length?this:new Q(hr(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(s&1){let o=i.pop(),l=Pi(r.text,o.text.slice(),0,r.length);if(l.length<=32)i.push(new Q(l,o.length+r.length));else{let h=l.length>>1;i.push(new Q(l.slice(0,h)),new Q(l.slice(h)))}}else i.push(r)}replace(e,t,i){if(!(i instanceof Q))return super.replace(e,t,i);let s=Pi(this.text,Pi(i.text,hr(this.text,0,e)),t),r=this.length+i.length-(t-e);return s.length<=32?new Q(s,r):Ve.from(Q.split(s,[]),r)}sliceString(e,t=this.length,i=`
|
||||
`){let s="";for(let r=0,o=0;r<=t&&o<this.text.length;o++){let l=this.text[o],h=r+l.length;r>e&&o&&(s+=i),e<h&&t>r&&(s+=l.slice(Math.max(0,e-r),t-r)),r=h+1}return s}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],s=-1;for(let r of e)i.push(r),s+=r.length+1,i.length==32&&(t.push(new Q(i,s)),i=[],s=-1);return s>-1&&t.push(new Q(i,s)),t}}class Ve extends z{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.children[r],l=s+o.length,h=i+o.lines-1;if((t?h:l)>=e)return o.lineInner(e,t,i,s);s=l+1,i=h+1}}decompose(e,t,i,s){for(let r=0,o=0;o<=t&&r<this.children.length;r++){let l=this.children[r],h=o+l.length;if(e<=h&&t>=o){let a=s&((o<=e?1:0)|(h>=t?2:0));o>=e&&h<=t&&!a?i.push(l):l.decompose(e-o,t-o,i,a)}o=h+1}}replace(e,t,i){if(i.lines<this.lines)for(let s=0,r=0;s<this.children.length;s++){let o=this.children[s],l=r+o.length;if(e>=r&&t<=l){let h=o.replace(e-r,t-r,i),a=this.lines-o.lines+h.lines;if(h.lines<a>>5-1&&h.lines>a>>5+1){let c=this.children.slice();return c[s]=h,new Ve(c,this.length-(t-e)+i.length)}return super.replace(r,l,h)}r=l+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=`
|
||||
`){let s="";for(let r=0,o=0;r<this.children.length&&o<=t;r++){let l=this.children[r],h=o+l.length;o>e&&r&&(s+=i),e<h&&t>o&&(s+=l.sliceString(e-o,t-o,i)),o=h+1}return s}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof Ve))return 0;let i=0,[s,r,o,l]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;s+=t,r+=t){if(s==o||r==l)return i;let h=this.children[s],a=e.children[r];if(h!=a)return i+h.scanIdentical(a,t);i+=h.length+1}}static from(e,t=e.reduce((i,s)=>i+s.length+1,-1)){let i=0;for(let d of e)i+=d.lines;if(i<32){let d=[];for(let p of e)p.flatten(d);return new Q(d,t)}let s=Math.max(32,i>>5),r=s<<1,o=s>>1,l=[],h=0,a=-1,c=[];function f(d){let p;if(d.lines>r&&d instanceof Ve)for(let g of d.children)f(g);else d.lines>o&&(h>o||!h)?(u(),l.push(d)):d instanceof Q&&h&&(p=c[c.length-1])instanceof Q&&d.lines+p.lines<=32?(h+=d.lines,a+=d.length+1,c[c.length-1]=new Q(p.text.concat(d.text),p.length+1+d.length)):(h+d.lines>s&&u(),h+=d.lines,a+=d.length+1,c.push(d))}function u(){h!=0&&(l.push(c.length==1?c[0]:Ve.from(c,a)),a=-1,h=c.length=0)}for(let d of e)f(d);return u(),l.length==1?l[0]:new Ve(l,t)}}z.empty=new Q([""],0);function Ia(n){let e=-1;for(let t of n)e+=t.length+1;return e}function Pi(n,e,t=0,i=1e9){for(let s=0,r=0,o=!0;r<n.length&&s<=i;r++){let l=n[r],h=s+l.length;h>=t&&(h>i&&(l=l.slice(0,i-s)),s<t&&(l=l.slice(t-s)),o?(e[e.length-1]+=l,o=!1):e.push(l)),s=h+1}return e}function hr(n,e,t){return Pi(n,[""],e,t)}class Zt{constructor(e,t=1){this.dir=t,this.done=!1,this.lineBreak=!1,this.value="",this.nodes=[e],this.offsets=[t>0?1:(e instanceof Q?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let i=this.nodes.length-1,s=this.nodes[i],r=this.offsets[i],o=r>>1,l=s instanceof Q?s.text.length:s.children.length;if(o==(t>0?l:0)){if(i==0)return this.done=!0,this.value="",this;t>0&&this.offsets[i-1]++,this.nodes.pop(),this.offsets.pop()}else if((r&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=`
|
||||
`,this;e--}else if(s instanceof Q){let h=s.text[o+(t<0?-1:0)];if(this.offsets[i]+=t,h.length>Math.max(0,e))return this.value=e==0?h:t>0?h.slice(e):h.slice(0,h.length-e),this;e-=h.length}else{let h=s.children[o+(t<0?-1:0)];e>h.length?(e-=h.length,this.offsets[i]+=t):(t<0&&this.offsets[i]--,this.nodes.push(h),this.offsets.push(t>0?1:(h instanceof Q?h.text.length:h.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}}class zo{constructor(e,t,i){this.value="",this.done=!1,this.cursor=new Zt(e,t>i?-1:1),this.pos=t>i?e.length:0,this.from=Math.min(t,i),this.to=Math.max(t,i)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let i=t<0?this.pos-this.from:this.to-this.pos;e>i&&(e=i),i-=e;let{value:s}=this.cursor.next(e);return this.pos+=(s.length+e)*t,this.value=s.length<=i?s:t<0?s.slice(s.length-i):s.slice(0,i),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}}class qo{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:i,value:s}=this.inner.next(e);return t?(this.done=!0,this.value=""):i?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=s,this.afterBreak=!1),this}get lineBreak(){return!1}}typeof Symbol<"u"&&(z.prototype[Symbol.iterator]=function(){return this.iter()},Zt.prototype[Symbol.iterator]=zo.prototype[Symbol.iterator]=qo.prototype[Symbol.iterator]=function(){return this});class Na{constructor(e,t,i,s){this.from=e,this.to=t,this.number=i,this.text=s}get length(){return this.to-this.from}}let Dt="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(n=>n?parseInt(n,36):1);for(let n=1;n<Dt.length;n++)Dt[n]+=Dt[n-1];function Va(n){for(let e=1;e<Dt.length;e+=2)if(Dt[e]>n)return Dt[e-1]<=n;return!1}function ar(n){return n>=127462&&n<=127487}const cr=8205;function Ae(n,e,t=!0,i=!0){return(t?Ko:Fa)(n,e,i)}function Ko(n,e,t){if(e==n.length)return e;e&&Uo(n.charCodeAt(e))&&jo(n.charCodeAt(e-1))&&e--;let i=re(n,e);for(e+=ve(i);e<n.length;){let s=re(n,e);if(i==cr||s==cr||t&&Va(s))e+=ve(s),i=s;else if(ar(s)){let r=0,o=e-2;for(;o>=0&&ar(re(n,o));)r++,o-=2;if(r%2==0)break;e+=2}else break}return e}function Fa(n,e,t){for(;e>0;){let i=Ko(n,e-2,t);if(i<e)return i;e--}return 0}function Uo(n){return n>=56320&&n<57344}function jo(n){return n>=55296&&n<56320}function re(n,e){let t=n.charCodeAt(e);if(!jo(t)||e+1==n.length)return t;let i=n.charCodeAt(e+1);return Uo(i)?(t-55296<<10)+(i-56320)+65536:t}function Rs(n){return n<=65535?String.fromCharCode(n):(n-=65536,String.fromCharCode((n>>10)+55296,(n&1023)+56320))}function ve(n){return n<65536?1:2}const Kn=/\r\n?|\n/;var me=function(n){return n[n.Simple=0]="Simple",n[n.TrackDel=1]="TrackDel",n[n.TrackBefore=2]="TrackBefore",n[n.TrackAfter=3]="TrackAfter",n}(me||(me={}));class We{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;t<this.sections.length;t+=2)e+=this.sections[t];return e}get newLength(){let e=0;for(let t=0;t<this.sections.length;t+=2){let i=this.sections[t+1];e+=i<0?this.sections[t]:i}return e}get empty(){return this.sections.length==0||this.sections.length==2&&this.sections[1]<0}iterGaps(e){for(let t=0,i=0,s=0;t<this.sections.length;){let r=this.sections[t++],o=this.sections[t++];o<0?(e(i,s,r),s+=r):s+=o,i+=r}}iterChangedRanges(e,t=!1){Un(this,e,t)}get invertedDesc(){let e=[];for(let t=0;t<this.sections.length;){let i=this.sections[t++],s=this.sections[t++];s<0?e.push(i,s):e.push(s
Line truncated
|
||||
`}get readOnly(){return this.facet(tl)}phrase(e,...t){for(let i of this.facet(V.phrases))if(Object.prototype.hasOwnProperty.call(i,e)){e=i[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(i,s)=>{if(s=="$")return"$";let r=+(s||1);return!r||r>t.length?i:t[r-1]})),e}languageDataAt(e,t,i=-1){let s=[];for(let r of this.facet(Xo))for(let o of r(this,t,i))Object.prototype.hasOwnProperty.call(o,e)&&s.push(o[e]);return s}charCategorizer(e){return Xa(this.languageDataAt("wordChars",e).join(""))}wordAt(e){let{text:t,from:i,length:s}=this.doc.lineAt(e),r=this.charCategorizer(e),o=e-i,l=e-i;for(;o>0;){let h=Ae(t,o,!1);if(r(t.slice(h,o))!=ce.Word)break;o=h}for(;l<s;){let h=Ae(t,l);if(r(t.slice(l,h))!=ce.Word)break;l=h}return o==l?null:m.range(o+i,l+i)}}V.allowMultipleSelections=Yo;V.tabSize=T.define({combine:n=>n.length?n[0]:4});V.lineSeparator=_o;V.readOnly=tl;V.phrases=T.define({compare(n,e){let t=Object.keys(n),i=Object.keys(e);return t.length==i.length&&t.every(s=>n[s]==e[s])}});V.languageData=Xo;V.changeFilter=Qo;V.transactionFilter=Zo;V.transactionExtender=el;_e.reconfigure=H.define();function zt(n,e,t={}){let i={};for(let s of n)for(let r of Object.keys(s)){let o=s[r],l=i[r];if(l===void 0)i[r]=o;else if(!(l===o||o===void 0))if(Object.hasOwnProperty.call(t,r))i[r]=t[r](l,o);else throw new Error("Config merge conflict for field "+r)}for(let s in e)i[s]===void 0&&(i[s]=e[s]);return i}class pt{eq(e){return this==e}range(e,t=e){return ri.create(e,t,this)}}pt.prototype.startSide=pt.prototype.endSide=0;pt.prototype.point=!1;pt.prototype.mapMode=me.TrackDel;class ri{constructor(e,t,i){this.from=e,this.to=t,this.value=i}static create(e,t,i){return new ri(e,t,i)}}function Yn(n,e){return n.from-e.from||n.value.startSide-e.value.startSide}class Es{constructor(e,t,i,s){this.from=e,this.to=t,this.value=i,this.maxPoint=s}get length(){return this.to[this.to.length-1]}findIndex(e,t,i,s=0){let r=i?this.to:this.from;for(let o=s,l=r.length;;){if(o==l)return o;let h=o+l>>1,a=r[h]-e||(i?this.value[h].endSide:this.value[h].startSide)-t;if(h==o)return a>=0?o:l;a>=0?l=h:o=h+1}}between(e,t,i,s){for(let r=this.findIndex(t,-1e9,!0),o=this.findIndex(i,1e9,!1,r);r<o;r++)if(s(this.from[r]+e,this.to[r]+e,this.value[r])===!1)return!1}map(e,t){let i=[],s=[],r=[],o=-1,l=-1;for(let h=0;h<this.value.length;h++){let a=this.value[h],c=this.from[h]+e,f=this.to[h]+e,u,d;if(c==f){let p=t.mapPos(c,a.startSide,a.mapMode);if(p==null||(u=d=p,a.startSide!=a.endSide&&(d=t.mapPos(c,a.endSide),d<u)))continue}else if(u=t.mapPos(c,a.startSide),d=t.mapPos(f,a.endSide),u>d||u==d&&a.startSide>0&&a.endSide<=0)continue;(d-u||a.endSide-a.startSide)<0||(o<0&&(o=u),a.point&&(l=Math.max(l,d-u)),i.push(a),s.push(u-o),r.push(d-o))}return{mapped:i.length?new Es(s,r,i,l):null,pos:o}}}class Y{constructor(e,t,i,s){this.chunkPos=e,this.chunk=t,this.nextLayer=i,this.maxPoint=s}static create(e,t,i,s){return new Y(e,t,i,s)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:i=!1,filterFrom:s=0,filterTo:r=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(i&&(t=t.slice().sort(Yn)),this.isEmpty)return t.length?Y.of(t):this;let l=new sl(this,null,-1).goto(0),h=0,a=[],c=new gt;for(;l.value||h<t.length;)if(h<t.length&&(l.from-t[h].from||l.startSide-t[h].value.startSide)>=0){let f=t[h++];c.addInner(f.from,f.to,f.value)||a.push(f)}else l.rangeIndex==1&&l.chunkIndex<this.chunk.length&&(h==t.length||this.chunkEnd(l.chunkIndex)<t[h].from)&&(!o||s>this.chunkEnd(l.chunkIndex)||r<this.chunkPos[l.chunkIndex])&&c.addChunk(this.chunkPos[l.chunkIndex],this.chunk[l.chunkIndex])?l.nextChunk():((!o||s>l.to||r<l.from||o(l.from,l.to,l.value))&&(c.addInner(l.from,l.to,l.value)||a.push(ri.create(l.from,l.to,l.value))),l.next());return c.finishInner(this.nextLayer.isEmpty&&!a.length?Y.empty:this.nextLayer.update({add:a,filter:o,filterFrom:s,filterTo:r}))}map(e){if(e.empty||this.isEmpty)return this;let t=[],i=[],s=-1;for(let o=0;o<this.chunk.length;o++){let l=this.chunkPos[o],h=this.chunk[o],a=e.touchesRange(l,l+h.length);if(a===!1)s=Math.max(s,h.maxPoint),t.push(h),i.push(e.mapPos(l));else if(a===!0){let{mapped:c,pos:f}=h.map(l,e);c&&(s=Math.max(s,c.maxPoint),t.push(c),i.push(f))}}let r=this.nextLayer.map(e);return t.length==0?r:new Y(i,t,r||Y.empty,s)}between(e,t,i){if(!this.isEmpty){for(let s=0;s<this.chunk.length;s++){let r=this.chunkPos[s],o=this.chunk[s];if(t>=r&&e<=r+o.length&&o.between(r,e-r,t-r,i)===!1)return}this.nextLayer.between(e,t,i)}}iter(e=0){return oi.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return oi.from(e).goto(t)}static compare(e,t,i,s,r=-1){let o=e.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),l=t.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),h=dr(o,l,i),a=new Ut(o,h,r),c=new Ut(l
Line truncated
|
||||
`)}static newName(){let e=yr[mr]||1;return yr[mr]=e+1,Zn+e.toString(36)}static mount(e,t){(e[es]||new _a(e)).mount(Array.isArray(t)?t:[t])}}let bi=null;class _a{constructor(e){if(!e.head&&e.adoptedStyleSheets&&typeof CSSStyleSheet<"u"){if(bi)return e.adoptedStyleSheets=[bi.sheet].concat(e.adoptedStyleSheets),e[es]=bi;this.sheet=new CSSStyleSheet,e.adoptedStyleSheets=[this.sheet].concat(e.adoptedStyleSheets),bi=this}else{this.styleTag=(e.ownerDocument||e).createElement("style");let t=e.head||e;t.insertBefore(this.styleTag,t.firstChild)}this.modules=[],e[es]=this}mount(e){let t=this.sheet,i=0,s=0;for(let r=0;r<e.length;r++){let o=e[r],l=this.modules.indexOf(o);if(l<s&&l>-1&&(this.modules.splice(l,1),s--,l=-1),l==-1){if(this.modules.splice(s++,0,o),t)for(let h=0;h<o.rules.length;h++)t.insertRule(o.rules[h],i++)}else{for(;s<l;)i+=this.modules[s++].rules.length;i+=o.rules.length,s++}}if(!t){let r="";for(let o=0;o<this.modules.length;o++)r+=this.modules[o].getRules()+`
|
||||
`;this.styleTag.textContent=r}}}var rt={8:"Backspace",9:"Tab",10:"Enter",12:"NumLock",13:"Enter",16:"Shift",17:"Control",18:"Alt",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",44:"PrintScreen",45:"Insert",46:"Delete",59:";",61:"=",91:"Meta",92:"Meta",106:"*",107:"+",108:",",109:"-",110:".",111:"/",144:"NumLock",145:"ScrollLock",160:"Shift",161:"Shift",162:"Control",163:"Control",164:"Alt",165:"Alt",173:"-",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'",229:"q"},Rt={48:")",49:"!",50:"@",51:"#",52:"$",53:"%",54:"^",55:"&",56:"*",57:"(",59:":",61:"+",173:"_",186:":",187:"+",188:"<",189:"_",190:">",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"',229:"Q"},br=typeof navigator<"u"&&/Chrome\/(\d+)/.exec(navigator.userAgent),Qa=typeof navigator<"u"&&/Apple Computer/.test(navigator.vendor),Za=typeof navigator<"u"&&/Gecko\/\d+/.test(navigator.userAgent),wr=typeof navigator<"u"&&/Mac/.test(navigator.platform),ec=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent),tc=br&&(wr||+br[1]<57)||Za&≀for(var oe=0;oe<10;oe++)rt[48+oe]=rt[96+oe]=String(oe);for(var oe=1;oe<=24;oe++)rt[oe+111]="F"+oe;for(var oe=65;oe<=90;oe++)rt[oe]=String.fromCharCode(oe+32),Rt[oe]=String.fromCharCode(oe);for(var vn in rt)Rt.hasOwnProperty(vn)||(Rt[vn]=rt[vn]);function ic(n){var e=tc&&(n.ctrlKey||n.altKey||n.metaKey)||(Qa||ec)&&n.shiftKey&&n.key&&n.key.length==1,t=!e&&n.key||(n.shiftKey?Rt:rt)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function Hi(n){let e;return n.nodeType==11?e=n.getSelection?n:n.ownerDocument:e=n,e.getSelection()}function Pt(n,e){return e?n==e||n.contains(e.nodeType!=1?e.parentNode:e):!1}function nc(){let n=document.activeElement;for(;n&&n.shadowRoot;)n=n.shadowRoot.activeElement;return n}function ts(n,e){if(!e.anchorNode)return!1;try{return Pt(n,e.anchorNode)}catch{return!1}}function li(n){return n.nodeType==3?Lt(n,0,n.nodeValue.length).getClientRects():n.nodeType==1?n.getClientRects():[]}function Wi(n,e,t,i){return t?xr(n,e,t,i,-1)||xr(n,e,t,i,1):!1}function is(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e}function xr(n,e,t,i,s){for(;;){if(n==t&&e==i)return!0;if(e==(s<0?0:zi(n))){if(n.nodeName=="DIV")return!1;let r=n.parentNode;if(!r||r.nodeType!=1)return!1;e=is(n)+(s<0?0:1),n=r}else if(n.nodeType==1){if(n=n.childNodes[e+(s<0?-1:0)],n.nodeType==1&&n.contentEditable=="false")return!1;e=s<0?zi(n):0}else return!1}}function zi(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}const rl={left:0,right:0,top:0,bottom:0};function on(n,e){let t=e?n.left:n.right;return{left:t,right:t,top:n.top,bottom:n.bottom}}function sc(n){return{left:0,right:n.innerWidth,top:0,bottom:n.innerHeight}}function rc(n,e,t,i,s,r,o,l){let h=n.ownerDocument,a=h.defaultView;for(let c=n;c;)if(c.nodeType==1){let f,u=c==h.body;if(u)f=sc(a);else{if(c.scrollHeight<=c.clientHeight&&c.scrollWidth<=c.clientWidth){c=c.parentNode;continue}let g=c.getBoundingClientRect();f={left:g.left,right:g.left+c.clientWidth,top:g.top,bottom:g.top+c.clientHeight}}let d=0,p=0;if(s=="nearest")e.top<f.top?(p=-(f.top-e.top+o),t>0&&e.bottom>f.bottom+p&&(p=e.bottom-f.bottom+p+o)):e.bottom>f.bottom&&(p=e.bottom-f.bottom+o,t<0&&e.top-p<f.top&&(p=-(f.top+p-e.top+o)));else{let g=e.bottom-e.top,y=f.bottom-f.top;p=(s=="center"&&g<=y?e.top+g/2-y/2:s=="start"||s=="center"&&t<0?e.top-o:e.bottom-y+o)-f.top}if(i=="nearest"?e.left<f.left?(d=-(f.left-e.left+r),t>0&&e.right>f.right+d&&(d=e.right-f.right+d+r)):e.right>f.right&&(d=e.right-f.right+r,t<0&&e.left<f.left+d&&(d=-(f.left+d-e.left+r))):d=(i=="center"?e.left+(e.right-e.left)/2-(f.right-f.left)/2:i=="start"==l?e.left-r:e.right-(f.right-f.left)+r)-f.left,d||p)if(u)a.scrollBy(d,p);else{if(p){let g=c.scrollTop;c.scrollTop+=p,p=c.scrollTop-g}if(d){let g=c.scrollLeft;c.scrollLeft+=d,d=c.scrollLeft-g}e={left:e.left-d,top:e.top-p,right:e.right-d,bottom:e.bottom-p}}if(u)break;c=c.assignedSlot||c.parentNode,i=s="nearest"}else if(c.nodeType==11)c=c.host;else break}class oc{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){this.set(e.anchorNode,e.anchorOffset,e.focusNode,e.focusOffset)}set(e,t,i,s){this.anchorNode=e,this.anchorOffset=t,this.focusNode=i,this.focusOffset=s}}let vt=null;function ol(n){if(n.setActive)return n.setActive();if(vt)return n.focus(vt);let e=[];for(let t=n;t&&(e.push(t,t.scrollTop,t.scrollLeft),t!=t.ownerDocument);t=t.parentNode);if(n.focus(vt==null?{get preventScroll(){return vt={preventScroll:!0},!0}}:void 0),!vt){vt=!1;for(let t=0;t<e.length;){let i=e[t++],s=e[t++],r=e[t++];i.scrollTop!=s&&(i.Line truncated
|
||||
`,s=n.state.doc.line(s.number+(t?1:-1)),r=n.bidiSpans(s),a=m.cursor(t?s.from:s.to)}if(h){if(!h(c))return l}else{if(!i)return a;h=i(c)}l=a}}function Pc(n,e,t){let i=n.state.charCategorizer(e),s=i(t);return r=>{let o=i(r);return s==ce.Space&&(s=o),s==o}}function Lc(n,e,t,i){let s=e.head,r=t?1:-1;if(s==(t?n.state.doc.length:0))return m.cursor(s,e.assoc);let o=e.goalColumn,l,h=n.contentDOM.getBoundingClientRect(),a=n.coordsAtPos(s),c=n.documentTop;if(a)o==null&&(o=a.left-h.left),l=r<0?a.top:a.bottom;else{let d=n.viewState.lineBlockAt(s);o==null&&(o=Math.min(h.right-h.left,n.defaultCharacterWidth*(s-d.from))),l=(r<0?d.top:d.bottom)+c}let f=h.left+o,u=i!=null?i:n.defaultLineHeight>>1;for(let d=0;;d+=10){let p=l+(u+d)*r,g=Il(n,{x:f,y:p},!1,r);if(p<h.top||p>h.bottom||(r<0?g<s:g>s))return m.cursor(g,e.assoc,void 0,o)}}function Dn(n,e,t){let i=n.state.facet(Dl).map(s=>s(n));for(;;){let s=!1;for(let r of i)r.between(t.from-1,t.from+1,(o,l,h)=>{t.from>o&&t.from<l&&(t=e.from>t.from?m.cursor(o,1):m.cursor(l,-1),s=!0)});if(!s)return t}}class Ec{constructor(e){this.lastKeyCode=0,this.lastKeyTime=0,this.chromeScrollHack=-1,this.pendingIOSKey=void 0,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastEscPress=0,this.lastContextMenu=0,this.scrollHandlers=[],this.registeredEvents=[],this.customHandlers=[],this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.rapidCompositionStart=!1,this.mouseSelection=null;for(let t in ie){let i=ie[t];e.contentDOM.addEventListener(t,s=>{!Vr(e,s)||this.ignoreDuringComposition(s)||t=="keydown"&&this.keydown(e,s)||(this.mustFlushObserver(s)&&e.observer.forceFlush(),this.runCustomHandlers(t,e,s)?s.preventDefault():i(e,s))}),this.registeredEvents.push(t)}M.chrome&&M.chrome_version>=102&&e.scrollDOM.addEventListener("wheel",()=>{this.chromeScrollHack<0?e.contentDOM.style.pointerEvents="none":window.clearTimeout(this.chromeScrollHack),this.chromeScrollHack=setTimeout(()=>{this.chromeScrollHack=-1,e.contentDOM.style.pointerEvents=""},100)},{passive:!0}),this.notifiedFocused=e.hasFocus,M.safari&&e.contentDOM.addEventListener("input",()=>null)}setSelectionOrigin(e){this.lastSelectionOrigin=e,this.lastSelectionTime=Date.now()}ensureHandlers(e,t){var i;let s;this.customHandlers=[];for(let r of t)if(s=(i=r.update(e).spec)===null||i===void 0?void 0:i.domEventHandlers){this.customHandlers.push({plugin:r.value,handlers:s});for(let o in s)this.registeredEvents.indexOf(o)<0&&o!="scroll"&&(this.registeredEvents.push(o),e.contentDOM.addEventListener(o,l=>{!Vr(e,l)||this.runCustomHandlers(o,e,l)&&l.preventDefault()}))}}runCustomHandlers(e,t,i){for(let s of this.customHandlers){let r=s.handlers[e];if(r)try{if(r.call(s.plugin,i,t)||i.defaultPrevented)return!0}catch(o){Re(t.state,o)}}return!1}runScrollHandlers(e,t){for(let i of this.customHandlers){let s=i.handlers.scroll;if(s)try{s.call(i.plugin,t,e)}catch(r){Re(e.state,r)}}}keydown(e,t){if(this.lastKeyCode=t.keyCode,this.lastKeyTime=Date.now(),t.keyCode==9&&Date.now()<this.lastEscPress+2e3)return!0;if(M.android&&M.chrome&&!t.synthetic&&(t.keyCode==13||t.keyCode==8))return e.observer.delayAndroidKey(t.key,t.keyCode),!0;let i;return M.ios&&(i=Nl.find(s=>s.keyCode==t.keyCode))&&!(t.ctrlKey||t.altKey||t.metaKey)&&!t.synthetic?(this.pendingIOSKey=i,setTimeout(()=>this.flushIOSKey(e),250),!0):!1}flushIOSKey(e){let t=this.pendingIOSKey;return t?(this.pendingIOSKey=void 0,ti(e.contentDOM,t.key,t.keyCode)):!1}ignoreDuringComposition(e){return/^key/.test(e.type)?this.composing>0?!0:M.safari&&Date.now()-this.compositionEndedAt<100?(this.compositionEndedAt=0,!0):!1:!1}mustFlushObserver(e){return e.type=="keydown"&&e.keyCode!=229||e.type=="compositionend"&&!M.ios}startMouseSelection(e){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=e}update(e){this.mouseSelection&&this.mouseSelection.update(e),e.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}}const Nl=[{key:"Backspace",keyCode:8,inputType:"deleteContentBackward"},{key:"Enter",keyCode:13,inputType:"insertParagraph"},{key:"Delete",keyCode:46,inputType:"deleteContentForward"}],Vl=[16,17,18,20,91,92,224,225];class Ic{constructor(e,t,i,s){this.view=e,this.style=i,this.mustSelect=s,this.lastEvent=t;let r=e.contentDOM.ownerDocument;r.addEventListener("mousemove",this.move=this.move.bind(this)),r.addEventListener("mouseup",this.up=this.up.bind(this)),this.extend=t.shiftKey,this.multiple=e.state.facet(V.allowMultipleSelections)&&Nc(e,t),this.dragMove=Vc(e,t),this.dragging=Fc(e,t)&&Fs(t)==1?null:!1,this.dragging===!1&&(t.preventDefault(),this.select(t))}move(e){if(e.buttons==0)return this.destroy();this.dragging===!1&&this.select(this.lastEvent=e)}up(e){this.dragging==null&&this.select(this.lastEvent),this.dragging||e.preventDefault(),this.destroy()}destroy(){let e=this.view.contentDOM.ownerDocument;e.removeEventListener("mousemove",this.move),e.removeEventListener("mouseup",this.up),this.view.i
Line truncated
|
||||
-\x7F-\x9F\xAD\u061C\u200B\u200E\u200F\u2028\u2029\u202D\u202E\uFEFF\uFFF9-\uFFFC]`,ws),Bf={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"};let Bn=null;function Rf(){var n;if(Bn==null&&typeof document<"u"&&document.body){let e=document.body.style;Bn=((n=e.tabSize)!==null&&n!==void 0?n:e.MozTabSize)!=null}return Bn||!1}const Ii=T.define({combine(n){let e=zt(n,{render:null,specialChars:Tf,addSpecialChars:null});return(e.replaceTabs=!Rf())&&(e.specialChars=new RegExp(" |"+e.specialChars.source,ws)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+"|"+e.addSpecialChars.source,ws)),e}});function Pf(n={}){return[Ii.of(n),Lf()]}let so=null;function Lf(){return so||(so=ue.fromClass(class{constructor(n){this.view=n,this.decorations=R.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(n.state.facet(Ii)),this.decorations=this.decorator.createDeco(n)}makeDecorator(n){return new Of({regexp:n.specialChars,decoration:(e,t,i)=>{let{doc:s}=t.state,r=re(e[0],0);if(r==9){let o=s.lineAt(i),l=t.state.tabSize,h=ui(o.text,l,i-o.from);return R.replace({widget:new Vf((l-h%l)*this.view.defaultCharacterWidth)})}return this.decorationCache[r]||(this.decorationCache[r]=R.replace({widget:new Nf(n,r)}))},boundary:n.replaceTabs?void 0:/[^]/})}update(n){let e=n.state.facet(Ii);n.startState.facet(Ii)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(n.view)):this.decorations=this.decorator.updateDeco(n,this.decorations)}},{decorations:n=>n.decorations}))}const Ef="\u2022";function If(n){return n>=32?Ef:n==10?"\u2424":String.fromCharCode(9216+n)}class Nf extends xt{constructor(e,t){super(),this.options=e,this.code=t}eq(e){return e.code==this.code}toDOM(e){let t=If(this.code),i=e.state.phrase("Control character")+" "+(Bf[this.code]||"0x"+this.code.toString(16)),s=this.options.render&&this.options.render(this.code,i,t);if(s)return s;let r=document.createElement("span");return r.textContent=t,r.title=i,r.setAttribute("aria-label",i),r.className="cm-specialChar",r}ignoreEvent(){return!1}}class Vf extends xt{constructor(e){super(),this.width=e}eq(e){return e.width==this.width}toDOM(){let e=document.createElement("span");return e.textContent=" ",e.className="cm-tab",e.style.width=this.width+"px",e}ignoreEvent(){return!1}}class Ff extends xt{constructor(e){super(),this.content=e}toDOM(){let e=document.createElement("span");return e.className="cm-placeholder",e.style.pointerEvents="none",e.appendChild(typeof this.content=="string"?document.createTextNode(this.content):this.content),typeof this.content=="string"?e.setAttribute("aria-label","placeholder "+this.content):e.setAttribute("aria-hidden","true"),e}ignoreEvent(){return!1}}function ro(n){return ue.fromClass(class{constructor(e){this.view=e,this.placeholder=R.set([R.widget({widget:new Ff(n),side:1}).range(0)])}get decorations(){return this.view.state.doc.length?R.none:this.placeholder}},{decorations:e=>e.decorations})}const xs=2e3;function Hf(n,e,t){let i=Math.min(e.line,t.line),s=Math.max(e.line,t.line),r=[];if(e.off>xs||t.off>xs||e.col<0||t.col<0){let o=Math.min(e.off,t.off),l=Math.max(e.off,t.off);for(let h=i;h<=s;h++){let a=n.doc.line(h);a.length<=l&&r.push(m.range(a.from+o,a.to+l))}}else{let o=Math.min(e.col,t.col),l=Math.max(e.col,t.col);for(let h=i;h<=s;h++){let a=n.doc.line(h),c=Qn(a.text,o,n.tabSize,!0);if(c>-1){let f=Qn(a.text,l,n.tabSize);r.push(m.range(a.from+c,a.from+f))}}}return r}function Wf(n,e){let t=n.coordsAtPos(n.viewport.from);return t?Math.round(Math.abs((t.left-e)/n.defaultCharacterWidth)):-1}function oo(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1),i=n.state.doc.lineAt(t),s=t-i.from,r=s>xs?-1:s==i.length?Wf(n,e.clientX):ui(i.text,n.state.tabSize,t-i.from);return{line:i.number,col:r,off:s}}function zf(n,e){let t=oo(n,e),i=n.state.selection;return t?{update(s){if(s.docChanged){let r=s.changes.mapPos(s.startState.doc.line(t.line).from),o=s.state.doc.lineAt(r);t={line:o.number,col:t.col,off:Math.min(t.off,o.length)},i=i.map(s.changes)}},get(s,r,o){let l=oo(n,s);if(!l)return i;let h=Hf(n.state,t,l);return h.length?o?m.create(h.concat(i.ranges)):m.create(h):i}}:null}function qf(n){let e=(n==null?void 0:n.eventFilter)||(t=>t.altKey&&t.button==0);return O.mouseSelectionStyle.of((t,i)=>e(i)?zf(t,i):null)}const Rn="-10000px";class Kf{constructor(e,t,i){this.facet=t,this.createTooltipView=i,this.input=e.state.facet(t),this.tooltips=this.input.filter(s=>s),this.tooltipViews=this.tooltips.map(i)}update(e){let t=e.state.facet(this.facet),i=t.filter(r=>r);if(t===this.input){for(let r of this.tooltipViews)r.update&&r.update(e);return!1}let s=[];for(let
Line truncated
|
||||
`&&(t="");else{let i=t.indexOf(`
|
||||
`);i>-1&&(t=t.slice(0,i))}return e+t.length<=this.to?t:t.slice(0,this.to-e)}nextLine(){let e=this.parsedPos,t=this.lineAfter(e),i=e+t.length;for(let s=this.rangeIndex;;){let r=this.ranges[s].to;if(r>=i||(t=t.slice(0,r-(i-t.length)),s++,s==this.ranges.length))break;let o=this.ranges[s].from,l=this.lineAfter(o);t+=l,i=o+l.length}return{line:t,end:i}}skipGapsTo(e,t,i){for(;;){let s=this.ranges[this.rangeIndex].to,r=e+t;if(i>0?s>r:s>=r)break;t+=this.ranges[++this.rangeIndex].from-s}return t}moveRangeIndex(){for(;this.ranges[this.rangeIndex].to<this.parsedPos;)this.rangeIndex++}emitToken(e,t,i,s,r){if(this.ranges.length>1){r=this.skipGapsTo(t,r,1),t+=r;let o=this.chunk.length;r=this.skipGapsTo(i,r,-1),i+=r,s+=this.chunk.length-o}return this.chunk.push(e,t,i,s),r}parseLine(e){let{line:t,end:i}=this.nextLine(),s=0,{streamParser:r}=this.lang,o=new bh(t,e?e.state.tabSize:4,e?bt(e.state):2);if(o.eol())r.blankLine(this.state,o.indentUnit);else for(;!o.eol();){let l=xh(r.token,o,this.state);if(l&&(s=this.emitToken(this.lang.tokenTable.resolve(l),this.parsedPos+o.start,this.parsedPos+o.pos,4,s)),o.start>1e4)break}this.parsedPos=i,this.moveRangeIndex(),this.parsedPos<this.to&&this.parsedPos++}finishChunk(){let e=_.build({buffer:this.chunk,start:this.chunkStart,length:this.parsedPos-this.chunkStart,nodeSet:Wu,topID:0,maxBufferLength:2048,reused:this.chunkReused});e=new _(e.type,e.children,e.positions,e.length,[[this.lang.stateAfter,this.lang.streamParser.copyState(this.state)]]),this.chunks.push(e),this.chunkPos.push(this.chunkStart-this.ranges[0].from),this.chunk=[],this.chunkReused=void 0,this.chunkStart=this.parsedPos}finish(){return new _(this.lang.topNode,this.chunks,this.chunkPos,this.parsedPos-this.ranges[0].from).balance()}}function xh(n,e,t){e.start=e.pos;for(let i=0;i<10;i++){let s=n(e,t);if(e.pos>e.start)return s}throw new Error("Stream parser failed to advance stream.")}const Xs=Object.create(null),ci=[we.none],Wu=new zs(ci),yo=[],kh=Object.create(null);for(let[n,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])kh[n]=vh(Xs,e);class Sh{constructor(e){this.extra=e,this.table=Object.assign(Object.create(null),kh)}resolve(e){return e?this.table[e]||(this.table[e]=vh(this.extra,e)):0}}const zu=new Sh(Xs);function Fn(n,e){yo.indexOf(n)>-1||(yo.push(n),console.warn(e))}function vh(n,e){let t=null;for(let r of e.split(".")){let o=n[r]||k[r];o?typeof o=="function"?t?t=o(t):Fn(r,`Modifier ${r} used at start of tag`):t?Fn(r,`Tag ${r} used as modifier`):t=o:Fn(r,`Unknown highlighting tag ${r}`)}if(!t)return 0;let i=e.replace(/ /g,"_"),s=we.define({id:ci.length,name:i,props:[lu({[i]:t})]});return ci.push(s),s.id}function qu(n){let e=we.define({id:ci.length,name:"Document",props:[ai.add(()=>n)]});return ci.push(e),e}const Ku=n=>{let e=_s(n.state);return e.line?Uu(n):e.block?Gu(n):!1};function Ys(n,e){return({state:t,dispatch:i})=>{if(t.readOnly)return!1;let s=n(e,t);return s?(i(t.update(s)),!0):!1}}const Uu=Ys(Xu,0),ju=Ys(Ch,0),Gu=Ys((n,e)=>Ch(n,e,$u(e)),0);function _s(n,e=n.selection.main.head){let t=n.languageDataAt("commentTokens",e);return t.length?t[0]:{}}const Jt=50;function Ju(n,{open:e,close:t},i,s){let r=n.sliceDoc(i-Jt,i),o=n.sliceDoc(s,s+Jt),l=/\s*$/.exec(r)[0].length,h=/^\s*/.exec(o)[0].length,a=r.length-l;if(r.slice(a-e.length,a)==e&&o.slice(h,h+t.length)==t)return{open:{pos:i-l,margin:l&&1},close:{pos:s+h,margin:h&&1}};let c,f;s-i<=2*Jt?c=f=n.sliceDoc(i,s):(c=n.sliceDoc(i,i+Jt),f=n.sliceDoc(s-Jt,s));let u=/^\s*/.exec(c)[0].length,d=/\s*$/.exec(f)[0].length,p=f.length-d-t.length;return c.slice(u,u+e.length)==e&&f.slice(p,p+t.length)==t?{open:{pos:i+u+e.length,margin:/\s/.test(c.charAt(u+e.length))?1:0},close:{pos:s-d-t.length,margin:/\s/.test(f.charAt(p-1))?1:0}}:null}function $u(n){let e=[];for(let t of n.selection.ranges){let i=n.doc.lineAt(t.from),s=t.to<=i.to?i:n.doc.lineAt(t.to),r=e.length-1;r>=0&&e[r].to>i.from?e[r].to=s.to:e.push({from:i.from,to:s.to})}return e}function Ch(n,e,t=e.selection.ranges){let i=t.map(r=>_s(e,r.from).block);if(!i.every(r=>r))return null;let s=t.map((r,o)=>Ju(e,i[o],r.from,r.to));if(n!=2&&!s.every(r=>r))return{changes:e.changes(t.map((r,o)=>s[o]?[]:[{from:r.from,insert:i[o].open+" "},{from:r.to,insert:" "+i[o].close}]))};if(n!=1&&s.some(r=>r)){let r=[];for(let o=0,l;o<s.length;o++)if(l=s[o]){let h=i[o],{open:a,close:c}=l;r.push({from:a.pos-h.open.length,to:a.pos+a.margin},{from:c.pos-c.margin,to:c.pos+h.close.length})}return{changes:r}}return null}function Xu(n,e,t=e.selection.ranges){let i=[],s=-1;for(let{from:r,to:o}of t){let l=i.length,h=1e9;for(let a=r;a<=o;){let c=e.doc.lineAt(a);if(c.from>s&&(r==o||o>c.from)){s=c.from;let f=_s(e,a).line;if(!f)continue;let u=/^\s*/.exec(c.text)[0].length,d=u==c
Line truncated
|
||||
`:i=="r"?"\r":i=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp}create(){return this.regexp?new op(this):new rp(this)}getCursor(e,t=0,i=e.length){return this.regexp?Mt(this,e,t,i):At(this,e,t,i)}}class la{constructor(e){this.spec=e}}function At(n,e,t,i){return new Ht(e,n.unquoted,t,i,n.caseSensitive?void 0:s=>s.toLowerCase())}class rp extends la{constructor(e){super(e)}nextMatch(e,t,i){let s=At(this.spec,e,i,e.length).nextOverlapping();return s.done&&(s=At(this.spec,e,0,t).nextOverlapping()),s.done?null:s.value}prevMatchInRange(e,t,i){for(let s=i;;){let r=Math.max(t,s-1e4-this.spec.unquoted.length),o=At(this.spec,e,r,s),l=null;for(;!o.nextOverlapping().done;)l=o.value;if(l)return l;if(r==t)return null;s-=1e4}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.length)}getReplacement(e){return this.spec.replace}matchAll(e,t){let i=At(this.spec,e,0,e.length),s=[];for(;!i.next().done;){if(s.length>=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=At(this.spec,e,Math.max(0,t-this.spec.unquoted.length),Math.min(i+this.spec.unquoted.length,e.length));for(;!r.next().done;)s(r.value.from,r.value.to)}}function Mt(n,e,t,i){return new na(e,n.search,n.caseSensitive?void 0:{ignoreCase:!0},t,i)}class op extends la{nextMatch(e,t,i){let s=Mt(this.spec,e,i,e.length).next();return s.done&&(s=Mt(this.spec,e,0,t).next()),s.done?null:s.value}prevMatchInRange(e,t,i){for(let s=1;;s++){let r=Math.max(t,i-s*1e4),o=Mt(this.spec,e,r,i),l=null;for(;!o.next().done;)l=o.value;if(l&&(r==t||l.from>r+10))return l;if(r==t)return null}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.length)}getReplacement(e){return this.spec.replace.replace(/\$([$&\d+])/g,(t,i)=>i=="$"?"$":i=="&"?e.match[0]:i!="0"&&+i<e.match.length?e.match[i]:t)}matchAll(e,t){let i=Mt(this.spec,e,0,e.length),s=[];for(;!i.next().done;){if(s.length>=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=Mt(this.spec,e,Math.max(0,t-250),Math.min(i+250,e.length));for(;!r.next().done;)s(r.value.from,r.value.to)}}const fi=H.define(),tr=H.define(),it=ke.define({create(n){return new zn(Ts(n).create(),null)},update(n,e){for(let t of e.effects)t.is(fi)?n=new zn(t.value.create(),n.panel):t.is(tr)&&(n=new zn(n.query,t.value?ir:null));return n},provide:n=>Gi.from(n,e=>e.panel)});class zn{constructor(e,t){this.query=e,this.panel=t}}const lp=R.mark({class:"cm-searchMatch"}),hp=R.mark({class:"cm-searchMatch cm-searchMatch-selected"}),ap=ue.fromClass(class{constructor(n){this.view=n,this.decorations=this.highlight(n.state.field(it))}update(n){let e=n.state.field(it);(e!=n.startState.field(it)||n.docChanged||n.selectionSet||n.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:n,panel:e}){if(!e||!n.spec.valid)return R.none;let{view:t}=this,i=new gt;for(let s=0,r=t.visibleRanges,o=r.length;s<o;s++){let{from:l,to:h}=r[s];for(;s<o-1&&h>r[s+1].from-2*250;)h=r[++s].to;n.highlight(t.state.doc,l,h,(a,c)=>{let f=t.state.selection.ranges.some(u=>u.from==a&&u.to==c);i.add(a,c,f?hp:lp)})}return i.finish()}},{decorations:n=>n.decorations});function pi(n){return e=>{let t=e.state.field(it,!1);return t&&t.query.spec.valid?n(e,t):ha(e)}}const en=pi((n,{query:e})=>{let{from:t,to:i}=n.state.selection.main,s=e.nextMatch(n.state.doc,t,i);return!s||s.from==t&&s.to==i?!1:(n.dispatch({selection:{anchor:s.from,head:s.to},scrollIntoView:!0,effects:nr(n,s),userEvent:"select.search"}),!0)}),tn=pi((n,{query:e})=>{let{state:t}=n,{from:i,to:s}=t.selection.main,r=e.prevMatch(t.doc,i,s);return r?(n.dispatch({selection:{anchor:r.from,head:r.to},scrollIntoView:!0,effects:nr(n,r),userEvent:"select.search"}),!0):!1}),cp=pi((n,{query:e})=>{let t=e.matchAll(n.state.doc,1e3);return!t||!t.length?!1:(n.dispatch({selection:m.create(t.map(i=>m.range(i.from,i.to))),userEvent:"select.search.matches"}),!0)}),fp=({state:n,dispatch:e})=>{let t=n.selection;if(t.ranges.length>1||t.main.empty)return!1;let{from:i,to:s}=t.main,r=[],o=0;for(let l=new Ht(n.doc,n.sliceDoc(i,s));!l.next().done;){if(r.length>1e3)return!1;l.value.from==i&&(o=r.length),r.push(m.range(l.value.from,l.value.to))}return e(n.update({selection:m.create(r,o),userEvent:"select.search.matches"})),!0},Po=pi((n,{query:e})=>{let{state:t}=n,{from:i,to:s}=t.selection.main;if(t.readOnly)return!1;let r=e.nextMatch(t.doc,i,i);if(!r)return!1;let o=[],l,h,a=[];if(r.from==i&&r.to==s&&(h=t.toText(e.getReplacement(r)),o.push({from:r.from,to:r.to,insert:h}),r=e.nextMatch(t.doc,r.from,r.to),a.push(O.announce.of(t.phrase("replaced match on line $",t.doc.lineAt(i).number)+"."))),r){let c=o.length==0||o[0].from>=r.to?0:r.to-r.from-h.length;l={anchor:r.from-c,head:r.to-c},a.push(nr(n,r))}return n.dispatch({changes:o,selection:l,scrollIntoView:!!l,effects:a,userEvent:"input.replace"}),!0}),up=pi((n,{query:e})=>{if(n.state.readOnly)return!1;let t=e.matchAll(n.state.doc,1e9).map(s=>{let{from:r,to:o}=s;re
Line truncated
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user