Compare commits

...
90 Commits
Author SHA1 Message Date
Gani Georgiev 8ca439e5f7 updated ui/dist 2023-04-03 21:11:04 +03:00
Gani Georgiev f31a3b133c revert part of the old COALESCE handling to support missing joined relation fields comparison with empty string 2023-04-03 20:27:52 +03:00
Gani Georgiev 850fe353da updated changelog 2023-04-01 13:00:28 +03:00
Gani Georgiev 16a6b32a76 updated go deps 2023-04-01 12:55:42 +03:00
Gani Georgiev a1487c3235 updated the record file handling for the before update hook too 2023-04-01 12:51:00 +03:00
Gani Georgiev 5255515e26 updated ui/dist 2023-04-01 12:38:04 +03:00
Gani Georgiev 0b90ce43c3 Merge branch 'develop' 2023-04-01 12:36:10 +03:00
Gani Georgiev 899b8217e0 fix realtime events firing before files upload completion 2023-04-01 11:47:01 +03:00
Gani Georgiev 69bf9779d9 added Dao.Clone() helper 2023-04-01 11:45:08 +03:00
Gani Georgiev 48d6803d17 check only the existence of the thumb and add ContentType metadata when creating the thumb 2023-03-31 23:06:22 +03:00
Gani Georgiev 216efb95a8 updated Dao.Save godoc 2023-03-30 21:19:14 +03:00
Gani GeorgievandYuxiang Gao 29a264e132 [#1346] upgraded to aws-sdk-v2
Co-authored-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2023-03-30 16:10:13 +03:00
Gani Georgiev 976a9e2f27 updated tinymce 2023-03-30 14:42:28 +03:00
Gani Georgiev 005e77151d removed unused tinymce themes 2023-03-30 14:35:43 +03:00
Gani Georgiev f12467f2b3 updated list api docs 2023-03-30 12:25:33 +03:00
Gani Georgiev fe03dd0789 use backtick in index column preset 2023-03-30 12:17:56 +03:00
Gani Georgiev ff1f99436a prevent closing the datefield options on calendar select and updated collections upsert save handler 2023-03-28 09:06:16 +03:00
Gani Georgiev 3ea02c945d updated changelog and formatting 2023-03-28 07:59:37 +03:00
Gani Georgiev 4c903684d8 added linux cgo target 2023-03-27 21:39:46 +03:00
Gani Georgiev d0239f25ed updated changelog and ui/dist 2023-03-27 19:44:09 +03:00
Gani Georgiev 05adb8c018 update ui deps 2023-03-27 16:17:37 +03:00
Gani Georgiev c901c9ab7d updated go deps 2023-03-27 16:16:09 +03:00
Gani Georgiev c9fba9972a cleanup old remaining temp views 2023-03-27 16:07:47 +03:00
Gani Georgiev 2c40811fac added required field marker 2023-03-27 16:07:20 +03:00
Gani Georgiev b81112f82e minor ui adjustments 2023-03-26 22:49:33 +03:00
Gani Georgiev 0acf8198a4 revert record upsert changes on upload failure 2023-03-26 22:49:18 +03:00
Gani Georgiev 5ece360808 autoselect new field names 2023-03-26 20:32:41 +03:00
Gani Georgiev 3a5d3d521f added ProviderName and ProviderClient fields to core.RecordAuthWithOAuth2Event 2023-03-26 19:32:23 +03:00
Gani Georgiev f024de3cc4 updated ui/dist 2023-03-26 19:19:44 +03:00
Gani Georgiev a4abe9e2cb removed unused AuthProviderConfig.Meta field 2023-03-25 23:34:00 +02:00
Gani Georgiev 5678339af0 added migrate history-sync command 2023-03-25 21:48:19 +02:00
Gani Georgiev e5a22b8bd8 added a flag indicating OAuth2 auth record creation 2023-03-25 15:18:28 +02:00
Gani Georgiev be0ee7f66c fixed generateAppleClientSecret call 2023-03-24 22:48:32 +02:00
Gani Georgiev 78a9f7429c updated ui/dist 2023-03-24 22:30:57 +02:00
Gani Georgiev d8b4daf47f updated rule field styles 2023-03-23 22:59:02 +02:00
Gani Georgiev 7a2360d785 [#2029] enable any id aliased column in a view query to be detected as relation field 2023-03-23 19:51:06 +02:00
Gani Georgiev 67ecebe935 [#1939] removed redundant COALESCE normalizations 2023-03-23 19:30:35 +02:00
Gani Georgiev 5cbdbf6c10 updated ui/dist 2023-03-23 16:15:19 +02:00
Gani Georgiev 0710214701 added new field indicator 2023-03-23 10:42:27 +02:00
Gani Georgiev 9736a45e80 renamed daos.GetTableColumns and daos.GetTableInfo for consistency 2023-03-22 17:15:17 +02:00
Gani Georgiev 923fc26a31 changed types.JsonArray to support generics 2023-03-22 17:12:44 +02:00
Gani Georgiev a79f3a7c56 removed test js file 2023-03-22 16:53:34 +02:00
Gani Georgiev 8430944650 deprecated SchemaField.Unique 2023-03-22 15:47:34 +02:00
Gani Georgiev 9b54fd3516 added debug log for already committed response error 2023-03-22 15:42:35 +02:00
Gani Georgiev 226352f884 replaced indirect expand field.Unique with unique index constraint check 2023-03-21 21:38:28 +02:00
Gani Georgiev 2a682a10b2 preload tinymce 2023-03-21 18:52:13 +02:00
Gani Georgiev b67549209a [#2118] added option to explicitly set the record id from the Admin UI 2023-03-21 17:56:31 +02:00
Gani Georgiev 1126a0e16f updated changelog and ui/dist 2023-03-21 15:55:42 +02:00
Gani Georgiev 17472cb40a minor internal indexes handling adjustments and test 2023-03-21 15:31:20 +02:00
Gani Georgiev 981de64c7f added Index.Build helper method 2023-03-21 14:26:44 +02:00
Gani Georgiev 1b45e23c81 removed unnecessary helper method and updated index parser regex 2023-03-19 22:15:18 +02:00
Gani Georgiev 29621303df updated indexes column migration to load unique fields and custom indexes 2023-03-19 22:13:52 +02:00
Gani Georgiev a0ec5707d1 (no tests) collection indexes scaffoldings 2023-03-19 16:18:33 +02:00
Gani Georgiev 695c20a969 decreased bcrypt round hash for admins to 12 for consistency with the auth records 2023-03-19 16:10:11 +02:00
Gani Georgiev 95bb2eb871 update automigrate templates to check collection indexes 2023-03-19 16:02:29 +02:00
Gani Georgiev 971916c20d removed unnecessary mail layou css class 2023-03-19 10:16:23 +02:00
Gani Georgiev 44f5172db7 added create index sql parser 2023-03-19 10:15:26 +02:00
Gani Georgiev 5fd103481c updated ui/dist 2023-03-19 10:14:44 +02:00
Gani Georgiev 82abd7e0b0 minor collection ui adjustments 2023-03-19 08:17:36 +02:00
Gani Georgiev 361cfb56b7 [#1904] simplified default mail template styles 2023-03-16 22:35:03 +02:00
Gani Georgiev 4d16d0e16e new schema and indexes ui 2023-03-16 19:21:16 +02:00
Gani Georgiev 254e691e92 [#2072] registered RemoveTrailingSlash middleware only for the /api/* routes 2023-03-15 18:09:49 +02:00
Gani Georgiev e735d9d21b synced with master 2023-03-14 02:04:34 +02:00
Gani Georgiev 31b24ecb86 removed eager unique collection name check to allow lazy validation during bulk import 2023-03-14 01:31:30 +02:00
Gani Georgiev 3eb3d0bbdf lowered auth record bcrypt round factor to 12 2023-03-13 15:52:51 +02:00
Gani Georgiev f180af7af7 synced with master 2023-03-12 18:09:16 +02:00
Gani Georgiev d046811df7 optimized single relation lookups 2023-03-07 23:28:35 +02:00
Gani Georgiev 4768e07c0b added multiple->single value conversion warning 2023-03-06 23:24:50 +02:00
Gani Georgiev 0547202382 use nonconcurrent db when deleting log requests 2023-03-06 23:24:31 +02:00
Gani Georgiev 684f91f7e5 added indirect view update by source collection field change 2023-03-06 20:10:18 +02:00
Gani Georgiev bce4094134 updated changelog 2023-03-06 15:32:46 +02:00
Gani Georgiev 6a60bc1df3 meged apple-oauth2 2023-03-06 15:30:35 +02:00
Gani Georgiev 19b7a7a74f updated ui/dist 2023-03-06 15:29:03 +02:00
Gani Georgiev 8de0e61cf4 synced with master 2023-03-06 15:27:17 +02:00
Gani Georgiev 5344ec83fa normalized values on maxSelect change 2023-03-06 15:20:07 +02:00
Gani Georgiev 65aa114103 added google OAuth2 verified_email check 2023-03-05 19:19:11 +02:00
Gani Georgiev 8728161288 sync with latest changes 2023-03-05 16:16:07 +02:00
Gani Georgiev 2420b2804a synced with master 2023-03-05 16:12:51 +02:00
Gani Georgiev 2791628d96 minor updates to the rule fields ui 2023-03-05 15:18:51 +02:00
Gani Georgiev 01f0af7af6 added errors slide transition 2023-03-04 13:06:24 +02:00
Gani Georgiev e8d61e7b45 simplified rules ui 2023-03-04 13:05:55 +02:00
Gani Georgiev a5ac83c7b0 use jwt.ParseECPrivateKeyFromPEM instead of the custom one 2023-03-02 21:31:27 +02:00
Gani Georgiev a67c14c368 added support for @request.headers.* 2023-03-02 18:56:18 +02:00
Gani Georgiev 19faa0d8e7 updated calendar styles 2023-03-02 16:10:56 +02:00
Gani Georgiev 56a77fd5ff updated migrations comment 2023-03-02 15:17:29 +02:00
Gani Georgiev 07727dbde6 [#1956] normalized _requests.method to UPPERCASE 2023-03-02 15:15:00 +02:00
Gani Georgiev a3d26a73c3 removed unnecessary struct pointer 2023-03-02 14:07:46 +02:00
Gani Georgiev b328827705 added generate-client-secret api test 2023-03-01 23:45:54 +02:00
Gani Georgiev f5e5fae773 added apple oauth2 integration 2023-03-01 23:29:51 +02:00
Gani Georgiev 41f01bab0d fixed collections import message typo 2023-02-28 16:50:25 +02:00
322 changed files with 7762 additions and 9788 deletions
+1 -1
View File
@@ -42,6 +42,6 @@ jobs:
with:
distribution: goreleaser
version: latest
args: release --rm-dist
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+25 -2
View File
@@ -7,7 +7,20 @@ before:
- go mod tidy
builds:
- main: ./examples/base
- id: build_cgo
main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
- id: build_noncgo
main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
@@ -33,7 +46,17 @@ release:
draft: true
archives:
- format: zip
- id: archive_cgo
builds: [build_cgo]
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}_cgo'
format: zip
files:
- LICENSE*
- CHANGELOG*
- id: archive_noncgo
builds: [build_noncgo]
format: zip
files:
- LICENSE*
- CHANGELOG*
+76
View File
@@ -1,3 +1,79 @@
## v0.14.2
- Reverted part of the old `COALESCE` handling to continue supporting empty string comparison with missing joined relation fields.
## v0.14.1
- Fixed realtime events firing before the files upload completion.
- Updated the underlying S3 lib to use `aws-sdk-go-v2` ([#1346](https://github.com/pocketbase/pocketbase/pull/1346); thanks @yuxiang-gao).
- Updated TinyMCE to v6.4.1.
- Updated the godoc of `Dao.Save*` methods.
## v0.14.0
- Added _experimental_ Apple OAuth2 integration.
- Added `@request.headers.*` filter rule support.
- Added support for advanced unique constraints and indexes management ([#345](https://github.com/pocketbase/pocketbase/issues/345), [#544](https://github.com/pocketbase/pocketbase/issues/544))
- Simplified the collections fields UI to allow easier and quicker scaffolding of the data schema.
- Deprecated `SchemaField.Unique`. Unique constraints are now managed via indexes.
The `Unique` field is a no-op and will be removed in future version.
- Removed the `COALESCE` wrapping from some of the generated filter conditions to make better use of the indexes ([#1939](https://github.com/pocketbase/pocketbase/issues/1939)).
- Detect `id` aliased view columns as single `relation` fields ([#2029](https://github.com/pocketbase/pocketbase/discussions/2029)).
- Optimized single relation lookups.
- Normalized record values on `maxSelect` field option change (`select`, `file`, `relation`).
When changing **from single to multiple** all already inserted single values are converted to an array.
When changing **from multiple to single** only the last item of the already inserted array items is kept.
- Changed the cost/round factor of bcrypt hash generation from 13 to 12 since several users complained about the slow authWithPassword responses on lower spec hardware.
_The change will affect only new users. Depending on the demand, we might make it configurable from the auth options._
- Simplified the default mail template styles to allow more control over the template layout ([#1904](https://github.com/pocketbase/pocketbase/issues/1904)).
- Added option to explicitly set the record id from the Admin UI ([#2118](https://github.com/pocketbase/pocketbase/issues/2118)).
- Added `migrate history-sync` command to clean `_migrations` history table from deleted migration files references.
- Added new fields to the `core.RecordAuthWithOAuth2Event` struct:
```
IsNewRecord bool, // boolean field indicating whether the OAuth2 action created a new auth record
ProviderName string, // the name of the OAuth2 provider (eg. "google")
ProviderClient auth.Provider, // the loaded Provider client instance
```
- Added CGO linux target for the prebuilt executable.
- **!** Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
- **!** Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
- **!** Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`.
- **!** Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
- **!** Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
- Other minor UI improvements.
## v0.13.4
- Removed eager unique collection name check to support lazy validation during bulk import.
## v0.13.3
- Fixed view collections import ([#2044](https://github.com/pocketbase/pocketbase/issues/2044)).
+5 -2
View File
@@ -36,8 +36,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
// default middlewares
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)
// enable by default only for the API routes
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
},
}))
e.Use(middleware.Recover())
@@ -47,6 +47,9 @@ func InitApi(app core.App) (*echo.Echo, error) {
// custom error handler
e.HTTPErrorHandler = func(c echo.Context, err error) {
if c.Response().Committed {
if app.IsDebug() {
log.Println("HTTPErrorHandler response was already committed:", err)
}
return
}
+73
View File
@@ -136,3 +136,76 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
scenario.Test(t)
}
}
func TestRemoveTrailingSlashMiddleware(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "non /api/* route (exact match)",
Method: http.MethodGet,
Url: "/custom",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "non /api/* route (with trailing slash)",
Method: http.MethodGet,
Url: "/custom/",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "/api/* route (exact match)",
Method: http.MethodGet,
Url: "/api/custom",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "/api/* route (with trailing slash)",
Method: http.MethodGet,
Url: "/api/custom/",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/custom",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+1 -1
View File
@@ -159,7 +159,7 @@ func (api *collectionApi) delete(c echo.Context) error {
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
+149 -1
View File
@@ -586,6 +586,83 @@ func TestCollectionCreate(t *testing.T) {
"OnCollectionAfterCreateRequest": 1,
},
},
// indexes
// -----------------------------------------------------------
{
Name: "creating base collection with invalid indexes",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"test"}
],
"indexes": [
"create index idx_test1 on new (test)",
"create index idx_test2 on new (missing)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"indexes":{"1":{"code":"`,
},
ExpectedEvents: map[string]int{
"OnCollectionBeforeCreateRequest": 1,
"OnModelBeforeCreate": 1,
},
},
{
Name: "creating base collection with valid indexes (+ random table name)",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"test"}
],
"indexes": [
"create index idx_test1 on new (test)",
"create index idx_test2 on anything (id, test)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"base"`,
`"indexes":[`,
`idx_test1`,
`idx_test2`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
}
expected := []string{"idx_test1", "idx_test2"}
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
}
}
},
},
}
for _, scenario := range scenarios {
@@ -951,6 +1028,68 @@ func TestCollectionUpdate(t *testing.T) {
"OnCollectionAfterUpdateRequest": 1,
},
},
// indexes
// -----------------------------------------------------------
{
Name: "updating base collection with invalid indexes",
Method: http.MethodPatch,
Url: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique idx_test1 on demo1 (text)",
"create index idx_test2 on demo2 (id, title)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"indexes":{"0":{"code":"`,
},
},
{
Name: "updating base collection with valid indexes (+ random table name)",
Method: http.MethodPatch,
Url: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique index idx_test1 on demo2 (title)",
"create index idx_test2 on anything (active)"
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"demo2"`,
`"indexes":[`,
"CREATE UNIQUE INDEX `idx_test1`",
"CREATE INDEX `idx_test2`",
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
indexes, err := app.Dao().TableIndexes("new")
if err != nil {
t.Fatal(err)
}
expected := []string{"idx_test1", "idx_test2"}
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
}
}
},
},
}
for _, scenario := range scenarios {
@@ -1018,7 +1157,7 @@ func TestCollectionImport(t *testing.T) {
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 6,
"OnModelBeforeDelete": 7,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
@@ -1097,6 +1236,9 @@ func TestCollectionImport(t *testing.T) {
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
},
{
@@ -1120,10 +1262,16 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := totalCollections + 3
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
indexes, err := app.Dao().TableIndexes("import2")
if err != nil || indexes["idx_test"] == "" {
t.Fatalf("Missing index %s (%v)", "idx_test", err)
}
},
},
{
+2 -5
View File
@@ -87,11 +87,8 @@ func (api *fileApi) download(c echo.Context) error {
servedName = thumbSize + "_" + filename
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
// check if the thumb exists:
// - if doesn't exist - create a new thumb with the specified thumb size
// - if exists - compare last modified dates to determine whether the thumb should be recreated
tAttrs, tAttrsErr := fs.Attributes(servedPath)
if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) {
// create a new thumb if it doesn exists
if exists, _ := fs.Exists(servedPath); !exists {
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
servedPath = originalPath // fallback to the original
}
+1 -1
View File
@@ -321,7 +321,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
model := &models.Request{
Url: httpRequest.URL.RequestURI(),
Method: strings.ToLower(httpRequest.Method),
Method: strings.ToUpper(httpRequest.Method),
Status: status,
Auth: requestAuth,
UserIp: realUserIp(httpRequest, ip),
+20 -7
View File
@@ -129,6 +129,16 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
codeVerifier := security.RandomString(43)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
urlOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
}
if name == auth.NameApple {
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
}
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
@@ -137,9 +147,8 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
urlOpts...,
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
})
}
@@ -168,10 +177,17 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
event := new(core.RecordAuthWithOAuth2Event)
event.HttpContext = c
event.Collection = collection
event.ProviderName = form.Provider
event.IsNewRecord = false
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := RequestData(c)
requestData.Data = form.CreateData
event.IsNewRecord = true
createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -204,14 +220,11 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
})
})
event := new(core.RecordAuthWithOAuth2Event)
event.HttpContext = c
event.Collection = collection
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
return func(data *forms.RecordOAuth2LoginData) error {
event.Record = data.Record
event.OAuth2User = data.OAuth2User
event.ProviderClient = data.ProviderClient
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
data.Record = e.Record
+12 -3
View File
@@ -30,9 +30,18 @@ func RequestData(c echo.Context) *models.RequestData {
}
result := &models.RequestData{
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
Headers: map[string]any{},
}
// extract the first value of all headers and normalizes the keys
// ("X-Token" is converted to "x_token")
for k, v := range c.Request().Header {
if len(v) > 0 {
result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
}
}
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
+7
View File
@@ -17,6 +17,7 @@ func TestRequestData(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("X-Token-Test", "123")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
@@ -38,6 +39,12 @@ func TestRequestData(t *testing.T) {
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
}
rawHeaders, _ := json.Marshal(result.Headers)
expectedHeaders := `{"content_type":"application/json","x_token_test":"123"}`
if v := string(rawHeaders); v != expectedHeaders {
t.Fatalf("Expected Query %v, got %v", expectedHeaders, v)
}
rawQuery, _ := json.Marshal(result.Query)
expectedQuery := `{"test":"123"}`
if v := string(rawQuery); v != expectedQuery {
+27 -1
View File
@@ -22,6 +22,7 @@ func bindSettingsApi(app core.App, rg *echo.Group) {
subGroup.PATCH("", api.set)
subGroup.POST("/test/s3", api.testS3)
subGroup.POST("/test/email", api.testEmail)
subGroup.POST("/apple/generate-client-secret", api.generateAppleClientSecret)
}
type settingsApi struct {
@@ -121,8 +122,8 @@ func (api *settingsApi) testEmail(c echo.Context) error {
// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
// form error
return NewBadRequestError("Failed to send the test email.", fErr)
}
@@ -132,3 +133,28 @@ func (api *settingsApi) testEmail(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
form := forms.NewAppleClientSecretCreate(api.app)
// load request
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
// generate
secret, err := form.Submit()
if err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
return NewBadRequestError("Invalid client secret data.", fErr)
}
// secret generation error
return NewBadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
}
return c.JSON(http.StatusOK, map[string]any{
"secret": secret,
})
}
+122
View File
@@ -1,6 +1,12 @@
package apis_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"strings"
"testing"
@@ -65,6 +71,7 @@ func TestSettingsList(t *testing.T) {
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -139,6 +146,7 @@ func TestSettingsSet(t *testing.T) {
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
@@ -202,6 +210,7 @@ func TestSettingsSet(t *testing.T) {
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -419,3 +428,116 @@ func TestSettingsTestEmail(t *testing.T) {
scenario.Test(t)
}
}
func TestGenerateAppleClientSecret(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedKey,
},
)
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (invalid body)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (empty json)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"clientId":{"code":"validation_required"`,
`"teamId":{"code":"validation_required"`,
`"keyId":{"code":"validation_required"`,
`"privateKey":{"code":"validation_required"`,
`"duration":{"code":"validation_required"`,
},
},
{
Name: "authorized as admin (invalid data)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{
"clientId": "",
"teamId": "123456789",
"keyId": "123456789",
"privateKey": "invalid",
"duration": -1
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"clientId":{"code":"validation_required"`,
`"teamId":{"code":"validation_length_invalid"`,
`"keyId":{"code":"validation_length_invalid"`,
`"privateKey":{"code":"validation_match_invalid"`,
`"duration":{"code":"validation_min_greater_equal_than_required"`,
},
},
{
Name: "authorized as admin (valid data)",
Method: http.MethodPost,
Url: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(fmt.Sprintf(`{
"clientId": "123",
"teamId": "1234567890",
"keyId": "1234567891",
"privateKey": %q,
"duration": 1
}`, privatePem)),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"secret":"`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+7 -5
View File
@@ -1,6 +1,7 @@
package core
import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
@@ -11,8 +12,6 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/labstack/echo/v5"
)
type BaseCollectionEvent struct {
@@ -206,9 +205,12 @@ type RecordAuthWithPasswordEvent struct {
type RecordAuthWithOAuth2Event struct {
BaseCollectionEvent
HttpContext echo.Context
Record *models.Record
OAuth2User *auth.AuthUser
HttpContext echo.Context
ProviderName string
ProviderClient auth.Provider
Record *models.Record
OAuth2User *auth.AuthUser
IsNewRecord bool
}
type RecordAuthRefreshEvent struct {
+13 -1
View File
@@ -74,6 +74,13 @@ func (dao *Dao) NonconcurrentDB() dbx.Builder {
return dao.nonconcurrentDB
}
// Clone returns a new Dao with the same configuration options as the current one.
func (dao *Dao) Clone() *Dao {
clone := *dao
return &clone
}
// ModelQuery creates a new preconfigured select query with preset
// SELECT, FROM and other common fields based on the provided model.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
@@ -110,6 +117,8 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
// ---
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
txDao := New(txOrDB)
txDao.MaxLockRetries = dao.MaxLockRetries
txDao.ModelQueryTimeout = dao.ModelQueryTimeout
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
@@ -205,7 +214,10 @@ func (dao *Dao) Delete(m models.Model) error {
})
}
// Save upserts (update or create if primary key is not set) the provided model.
// Save persists the provided model in the database.
//
// If m.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a model for update you can use m.MarkAsNotNew().
func (dao *Dao) Save(m models.Model) error {
if m.IsNew() {
return dao.lockRetry(func(retryDao *Dao) error {
+80
View File
@@ -40,6 +40,86 @@ func TestNewMultiDB(t *testing.T) {
}
}
func TestDaoClone(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
hookCalls := map[string]int{}
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
dao.MaxLockRetries = 1
dao.ModelQueryTimeout = 2
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["BeforeDeleteFunc"]++
return nil
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["BeforeUpdateFunc"]++
return nil
}
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
hookCalls["BeforeCreateFunc"]++
return nil
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["AfterDeleteFunc"]++
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["AfterUpdateFunc"]++
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["AfterCreateFunc"]++
}
clone := dao.Clone()
clone.MaxLockRetries = 3
clone.ModelQueryTimeout = 4
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
hookCalls["NewAfterCreateFunc"]++
}
if dao.MaxLockRetries == clone.MaxLockRetries {
t.Fatal("Expected different MaxLockRetries")
}
if dao.ModelQueryTimeout == clone.ModelQueryTimeout {
t.Fatal("Expected different ModelQueryTimeout")
}
// trigger hooks
dao.BeforeDeleteFunc(nil, nil)
dao.BeforeUpdateFunc(nil, nil)
dao.BeforeCreateFunc(nil, nil)
dao.AfterDeleteFunc(nil, nil)
dao.AfterUpdateFunc(nil, nil)
dao.AfterCreateFunc(nil, nil)
clone.BeforeDeleteFunc(nil, nil)
clone.BeforeUpdateFunc(nil, nil)
clone.BeforeCreateFunc(nil, nil)
clone.AfterDeleteFunc(nil, nil)
clone.AfterUpdateFunc(nil, nil)
clone.AfterCreateFunc(nil, nil)
expectations := []struct {
hook string
total int
}{
{"BeforeDeleteFunc", 2},
{"BeforeUpdateFunc", 2},
{"BeforeCreateFunc", 2},
{"AfterDeleteFunc", 2},
{"AfterUpdateFunc", 2},
{"AfterCreateFunc", 1},
{"NewAfterCreateFunc", 1},
}
for _, e := range expectations {
if hookCalls[e.hook] != e.total {
t.Errorf("Expected %s to be caleed %d", e.hook, e.total)
}
}
}
func TestDaoModelQuery(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+91 -9
View File
@@ -1,6 +1,8 @@
package daos
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -148,12 +150,20 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
}
}
// trigger views resave to check for dependencies
if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil {
return fmt.Errorf("The collection has a view dependency - %w", err)
}
return txDao.Delete(collection)
})
}
// SaveCollection upserts the provided Collection model and updates
// SaveCollection persists the provided Collection model and updates
// its related records table schema.
//
// If collecction.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a collection for update you can use collecction.MarkAsNotNew().
func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection
@@ -167,7 +177,7 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
}
}
return dao.RunInTransaction(func(txDao *Dao) error {
txErr := dao.RunInTransaction(func(txDao *Dao) error {
// set default collection type
if collection.Type == "" {
collection.Type = models.CollectionTypeBase
@@ -175,7 +185,9 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
switch collection.Type {
case models.CollectionTypeView:
return txDao.saveViewCollection(collection, oldCollection)
if err := txDao.saveViewCollection(collection, oldCollection); err != nil {
return err
}
default:
// persist the collection model
if err := txDao.Save(collection); err != nil {
@@ -183,9 +195,23 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
}
// sync the changes with the related records table
return txDao.SyncRecordTableSchema(collection, oldCollection)
if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil {
return err
}
}
return nil
})
if txErr != nil {
return txErr
}
// trigger an update for all views with changed schema as a result of the current collection save
// (ignoring view errors to allow users to update the query from the UI)
dao.resaveViewsWithChangedSchema(collection.Id)
return nil
}
// ImportCollections imports the provided collections list within a single transaction.
@@ -331,8 +357,8 @@ func (dao *Dao) ImportCollections(
// - saves the newCollection
//
// This method returns an error if newCollection is not a "view".
func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollection *models.Collection) error {
if newCollection.IsAuth() {
func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collection) error {
if !newCollection.IsView() {
return errors.New("not a view collection")
}
@@ -340,13 +366,13 @@ func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollecti
query := newCollection.ViewOptions().Query
// generate collection schema from the query
schema, err := txDao.CreateViewSchema(query)
viewSchema, err := txDao.CreateViewSchema(query)
if err != nil {
return err
}
// delete old renamed view
if oldCollection != nil && newCollection.Name != oldCollection.Name {
if oldCollection != nil {
if err := txDao.DeleteView(oldCollection.Name); err != nil {
return err
}
@@ -357,8 +383,64 @@ func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollecti
return err
}
newCollection.Schema = schema
newCollection.Schema = viewSchema
return txDao.Save(newCollection)
})
}
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
if err != nil {
return err
}
return dao.RunInTransaction(func(txDao *Dao) error {
for _, collection := range collections {
if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
continue
}
// clone the existing schema so that it is safe for temp modifications
oldSchema, err := collection.Schema.Clone()
if err != nil {
return err
}
// generate a new schema from the query
newSchema, err := txDao.CreateViewSchema(collection.ViewOptions().Query)
if err != nil {
return err
}
// unset the schema field ids to exclude from the comparison
for _, f := range oldSchema.Fields() {
f.Id = ""
}
for _, f := range newSchema.Fields() {
f.Id = ""
}
encodedNewSchema, err := json.Marshal(newSchema)
if err != nil {
return err
}
encodedOldSchema, err := json.Marshal(oldSchema)
if err != nil {
return err
}
if bytes.EqualFold(encodedNewSchema, encodedOldSchema) {
continue // no changes
}
if err := txDao.saveViewCollection(collection, nil); err != nil {
return err
}
}
return nil
})
}
+72 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestCollectionQuery(t *testing.T) {
@@ -186,7 +187,12 @@ func TestDeleteCollection(t *testing.T) {
t.Fatal(err)
}
colView, err := app.Dao().FindCollectionByNameOrId("view1")
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
colView2, err := app.Dao().FindCollectionByNameOrId("view2")
if err != nil {
t.Fatal(err)
}
@@ -199,7 +205,8 @@ func TestDeleteCollection(t *testing.T) {
{colAuth, false},
{colReferenced, true},
{colSystem, true},
{colView, false},
{colView1, true}, // view2 depend on it
{colView2, false},
}
for i, s := range scenarios {
@@ -252,7 +259,7 @@ func TestSaveCollectionCreate(t *testing.T) {
}
// check if the records table has the schema fields
columns, err := app.Dao().GetTableColumns(collection.Name)
columns, err := app.Dao().TableColumns(collection.Name)
if err != nil {
t.Fatal(err)
}
@@ -291,7 +298,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
// check if the records table has the schema fields
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
columns, err := app.Dao().GetTableColumns(collection.Name)
columns, err := app.Dao().TableColumns(collection.Name)
if err != nil {
t.Fatal(err)
}
@@ -305,6 +312,66 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
}
// indirect update of a field used in view should cause view(s) update
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
// update MaxSelect fields
{
relMany := collection.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
relManyOpt.MaxSelect = types.Pointer(1)
fileOne := collection.Schema.GetFieldByName("file_one")
fileOneOpt := fileOne.Options.(*schema.FileOptions)
fileOneOpt.MaxSelect = 10
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
}
// check view1 schema
{
view1, err := app.Dao().FindCollectionByNameOrId("view1")
if err != nil {
t.Fatal(err)
}
relMany := view1.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
}
fileOne := view1.Schema.GetFieldByName("file_one")
fileOneOpt := fileOne.Options.(*schema.FileOptions)
if fileOneOpt.MaxSelect != 10 {
t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOneOpt.MaxSelect)
}
}
// check view2 schema
{
view2, err := app.Dao().FindCollectionByNameOrId("view2")
if err != nil {
t.Fatal(err)
}
relMany := view2.Schema.GetFieldByName("rel_many")
relManyOpt := relMany.Options.(*schema.RelationOptions)
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
}
}
}
func TestImportCollections(t *testing.T) {
totalCollections := 10
@@ -435,7 +502,7 @@ func TestImportCollections(t *testing.T) {
},
{
"id":"wsmn24bux7wo113",
"name":"demo",
"name":"demo1_rename",
"schema":[
{
"id":"_2hlxbmp",
+13 -8
View File
@@ -247,9 +247,9 @@ func (dao *Dao) IsRecordValueUnique(
var normalizedVal any
switch val := value.(type) {
case []string:
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
normalizedVal = append(types.JsonArray[string]{}, val...)
case []any:
normalizedVal = append(types.JsonArray{}, val...)
normalizedVal = append(types.JsonArray[any]{}, val...)
default:
normalizedVal = val
}
@@ -386,7 +386,10 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
return username
}
// SaveRecord upserts the provided Record model.
// SaveRecord persists the provided Record model in the database.
//
// If record.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a record for update you can use record.MarkAsNotNew().
func (dao *Dao) SaveRecord(record *models.Record) error {
if record.Collection().IsAuth() {
if record.Username() == "" {
@@ -472,14 +475,16 @@ func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.
recordTableName := inflector.Columnify(refCollection.Name)
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
// @todo optimize single relation lookup
query := dao.RecordQuery(refCollection).
Distinct(true).
InnerJoin(fmt.Sprintf(
// note: the case is used to normalize the value access
query := dao.RecordQuery(refCollection).Distinct(true)
if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
} else {
query.InnerJoin(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
}
if refCollection.Id == mainRecord.Collection().Id {
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
+14 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
@@ -86,7 +87,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
}
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
if indirectRelFieldOptions.IsMultiple() {
// for now don't allow multi-relation indirect fields expand
// due to eventual poor query performance with large data sets.
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
@@ -126,7 +127,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
MaxSelect: nil,
CollectionId: indirectRel.Id,
}
if indirectRelField.Unique {
if isRelFieldUnique(indirectRel, indirectRelField.Name) {
relFieldOptions.MaxSelect = types.Pointer(1)
}
// indirect relation
@@ -269,3 +270,14 @@ func normalizeExpands(paths []string) []string {
return list.ToUniqueStringSlice(result)
}
func isRelFieldUnique(collection *models.Collection, fieldName string) bool {
for _, idx := range collection.Indexes {
parsed := dbutils.ParseIndex(idx)
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, fieldName) {
return true
}
}
return false
}
+54
View File
@@ -362,3 +362,57 @@ func TestExpandRecord(t *testing.T) {
}
}
}
func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
record, err := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
if err != nil {
t.Fatal(err)
}
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].([]*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a slice, got %v", result)
}
}
// mock a unique constraint for the rel_one_cascade field
{
demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
if err != nil {
t.Fatal(err)
}
demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)")
if err := app.Dao().SaveCollection(demo4); err != nil {
t.Fatalf("Failed to mock unique constraint: %v", err)
}
}
// non-unique indirect expand
{
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
})
if len(errs) > 0 {
t.Fatal(errs)
}
result, ok := record.Expand()["demo4(rel_one_cascade)"].(*models.Record)
if !ok {
t.Fatalf("Expected the expanded result to be a single model, got %v", result)
}
}
}
+235 -61
View File
@@ -2,10 +2,14 @@ package daos
import (
"fmt"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -15,70 +19,67 @@ import (
//
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
// create
if oldCollection == nil {
cols := map[string]string{
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
}
return dao.RunInTransaction(func(txDao *Dao) error {
// create
// -----------------------------------------------------------
if oldCollection == nil {
cols := map[string]string{
schema.FieldNameId: "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL",
schema.FieldNameCreated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
schema.FieldNameUpdated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
}
if newCollection.IsAuth() {
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
}
if newCollection.IsAuth() {
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
}
// ensure that the new collection has an id
if !newCollection.HasId() {
newCollection.RefreshId()
newCollection.MarkAsNew()
}
// ensure that the new collection has an id
if !newCollection.HasId() {
newCollection.RefreshId()
newCollection.MarkAsNew()
}
tableName := newCollection.Name
tableName := newCollection.Name
// add schema field definitions
for _, field := range newCollection.Schema.Fields() {
cols[field.Name] = field.ColDefinition()
}
// add schema field definitions
for _, field := range newCollection.Schema.Fields() {
cols[field.Name] = field.ColDefinition()
}
// create table
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add named index on the base `created` column
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := dao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
// create table
if _, err := txDao.DB().CreateTable(tableName, cols).Execute(); err != nil {
return err
}
// add named unique index on the email and tokenKey columns
if newCollection.IsAuth() {
_, err := txDao.DB().NewQuery(fmt.Sprintf(
`
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
`,
newCollection.Id, tableName,
newCollection.Id, tableName,
newCollection.Id, tableName,
)).Execute()
if err != nil {
return err
}
}
return txDao.createCollectionIndexes(newCollection)
}
return nil
}
// update
return dao.RunInTransaction(func(txDao *Dao) error {
// update
// -----------------------------------------------------------
oldTableName := oldCollection.Name
newTableName := newCollection.Name
oldSchema := oldCollection.Schema
@@ -86,9 +87,14 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
deletedFieldNames := []string{}
renamedFieldNames := map[string]string{}
// drop old indexes (if any)
if err := txDao.dropCollectionIndex(oldCollection); err != nil {
return err
}
// check for renamed table
if !strings.EqualFold(oldTableName, newTableName) {
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
_, err := txDao.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
if err != nil {
return err
}
@@ -102,7 +108,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
if err != nil {
return err
return fmt.Errorf("failed to drop column %s - %w", oldField.Name, err)
}
deletedFieldNames = append(deletedFieldNames, oldField.Name)
@@ -125,7 +131,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// add
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
if err != nil {
return err
return fmt.Errorf("failed to add column %s - %w", field.Name, err)
}
} else if oldField.Name != field.Name {
tempName := field.Name + security.PseudorandomString(5)
@@ -134,7 +140,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// rename
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
if err != nil {
return err
return fmt.Errorf("failed to rename column %s - %w", oldField.Name, err)
}
renamedFieldNames[oldField.Name] = field.Name
@@ -149,11 +155,109 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
}
}
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
if err := txDao.normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection); err != nil {
return err
}
if err := txDao.syncRelationDisplayFieldsChanges(newCollection, renamedFieldNames, deletedFieldNames); err != nil {
return err
}
return txDao.createCollectionIndexes(newCollection)
})
}
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection *models.Collection) error {
if newCollection.IsView() || oldCollection == nil {
return nil // view or not an update
}
return dao.RunInTransaction(func(txDao *Dao) error {
for _, newField := range newCollection.Schema.Fields() {
oldField := oldCollection.Schema.GetFieldById(newField.Id)
if oldField == nil {
continue
}
var isNewMultiple bool
if opt, ok := newField.Options.(schema.MultiValuer); ok {
isNewMultiple = opt.IsMultiple()
}
var isOldMultiple bool
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
isOldMultiple = opt.IsMultiple()
}
if isOldMultiple == isNewMultiple {
continue // no change
}
var updateQuery *dbx.Query
if !isOldMultiple && isNewMultiple {
// single -> multiple (convert to array)
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '') = ''
THEN '[]'
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN [[%s]]
ELSE json_array([[%s]])
END
)
END
)`,
newCollection.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
))
} else {
// multiple -> single (keep only the last element)
//
// note: for file fields the actual files are not deleted
// allowing additional custom handling via migration.
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '[]') = '[]'
THEN ''
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
ELSE [[%s]]
END
)
END
)`,
newCollection.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
newField.Name,
))
}
if _, err := updateQuery.Execute(); err != nil {
return err
}
}
return nil
})
}
func (dao *Dao) syncRelationDisplayFieldsChanges(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
return nil // nothing to sync
}
@@ -196,3 +300,73 @@ func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedF
return nil
}
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
}
return dao.RunInTransaction(func(txDao *Dao) error {
for _, raw := range collection.Indexes {
parsed := dbutils.ParseIndex(raw)
if !parsed.IsValid() {
continue
}
if _, err := txDao.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
return err
}
}
return nil
})
}
func (dao *Dao) createCollectionIndexes(collection *models.Collection) error {
if collection.IsView() {
return nil // views don't have indexes
}
return dao.RunInTransaction(func(txDao *Dao) error {
// drop new indexes in case a duplicated index name is used
if err := txDao.dropCollectionIndex(collection); err != nil {
return err
}
// upsert new indexes
//
// note: we are returning validation errors because the indexes cannot be
// validated in a form, aka. before persisting the related collection
// record table changes
errs := validation.Errors{}
for i, idx := range collection.Indexes {
parsed := dbutils.ParseIndex(idx)
// ensure that the index is always for the current collection
parsed.TableName = collection.Name
if !parsed.IsValid() {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
"Invalid CREATE INDEX expression.",
)
continue
}
if _, err := txDao.DB().NewQuery(parsed.Build()).Execute(); err != nil {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_invalid_index_expression",
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
)
continue
}
}
if len(errs) > 0 {
return validation.Errors{"indexes": errs}
}
return nil
})
}
+162 -22
View File
@@ -1,12 +1,16 @@
package daos_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestSyncRecordTableSchema(t *testing.T) {
@@ -36,15 +40,17 @@ func TestSyncRecordTableSchema(t *testing.T) {
Type: schema.FieldTypeEmail,
},
)
updatedCollection.Indexes = types.JsonArray[string]{"create index idx_title_renamed on anything (title_renamed)"}
scenarios := []struct {
newCollection *models.Collection
oldCollection *models.Collection
expectedTableName string
expectedColumns []string
name string
newCollection *models.Collection
oldCollection *models.Collection
expectedColumns []string
expectedIndexesCount int
}{
// new base collection
{
"new base collection",
&models.Collection{
Name: "new_table",
Schema: schema.NewSchema(
@@ -55,11 +61,11 @@ func TestSyncRecordTableSchema(t *testing.T) {
),
},
nil,
"new_table",
[]string{"id", "created", "updated", "test"},
0,
},
// new auth collection
{
"new auth collection",
&models.Collection{
Name: "new_table_auth",
Type: models.CollectionTypeAuth,
@@ -69,51 +75,185 @@ func TestSyncRecordTableSchema(t *testing.T) {
Type: schema.FieldTypeText,
},
),
Indexes: types.JsonArray[string]{"create index idx_auth_test on anything (email, username)"},
},
nil,
"new_table_auth",
[]string{
"id", "created", "updated", "test",
"username", "email", "verified", "emailVisibility",
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
},
4,
},
// no changes
{
"no changes",
oldCollection,
oldCollection,
"demo3",
[]string{"id", "created", "updated", "title", "active"},
3,
},
// renamed table, deleted column, renamed columnd and new column
{
"renamed table, deleted column, renamed columnd and new column",
updatedCollection,
oldCollection,
"demo_renamed",
[]string{"id", "created", "updated", "title_renamed", "new_field"},
1,
},
}
for i, scenario := range scenarios {
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
for _, s := range scenarios {
err := app.Dao().SyncRecordTableSchema(s.newCollection, s.oldCollection)
if err != nil {
t.Errorf("(%d) %v", i, err)
t.Errorf("[%s] %v", s.name, err)
continue
}
if !app.Dao().HasTable(scenario.newCollection.Name) {
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
if !app.Dao().HasTable(s.newCollection.Name) {
t.Errorf("[%s] Expected table %s to exist", s.name, s.newCollection.Name)
}
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
if len(cols) != len(scenario.expectedColumns) {
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
cols, _ := app.Dao().TableColumns(s.newCollection.Name)
if len(cols) != len(s.expectedColumns) {
t.Errorf("[%s] Expected columns %v, got %v", s.name, s.expectedColumns, cols)
}
for _, c := range cols {
if !list.ExistInSlice(c, scenario.expectedColumns) {
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
if !list.ExistInSlice(c, s.expectedColumns) {
t.Errorf("[%s] Couldn't find column %s in %v", s.name, c, s.expectedColumns)
}
}
indexes, _ := app.Dao().TableIndexes(s.newCollection.Name)
if totalIndexes := len(indexes); totalIndexes != s.expectedIndexesCount {
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.name, s.expectedIndexesCount, totalIndexes, indexes)
}
}
}
func TestSingleVsMultipleValuesNormalization(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
// mock field changes
{
selectOneField := collection.Schema.GetFieldByName("select_one")
opt := selectOneField.Options.(*schema.SelectOptions)
opt.MaxSelect = 2
}
{
selectManyField := collection.Schema.GetFieldByName("select_many")
opt := selectManyField.Options.(*schema.SelectOptions)
opt.MaxSelect = 1
}
{
fileOneField := collection.Schema.GetFieldByName("file_one")
opt := fileOneField.Options.(*schema.FileOptions)
opt.MaxSelect = 2
}
{
fileManyField := collection.Schema.GetFieldByName("file_many")
opt := fileManyField.Options.(*schema.FileOptions)
opt.MaxSelect = 1
}
{
relOneField := collection.Schema.GetFieldByName("rel_one")
opt := relOneField.Options.(*schema.RelationOptions)
opt.MaxSelect = types.Pointer(2)
}
{
relManyField := collection.Schema.GetFieldByName("rel_many")
opt := relManyField.Options.(*schema.RelationOptions)
opt.MaxSelect = types.Pointer(1)
}
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
type expectation struct {
SelectOne string `db:"select_one"`
SelectMany string `db:"select_many"`
FileOne string `db:"file_one"`
FileMany string `db:"file_many"`
RelOne string `db:"rel_one"`
RelMany string `db:"rel_many"`
}
scenarios := []struct {
recordId string
expected expectation
}{
{
"imy661ixudk5izi",
expectation{
SelectOne: `[]`,
SelectMany: ``,
FileOne: `[]`,
FileMany: ``,
RelOne: `[]`,
RelMany: ``,
},
},
{
"al1h9ijdeojtsjy",
expectation{
SelectOne: `["optionB"]`,
SelectMany: `optionB`,
FileOne: `["300_Jsjq7RdBgA.png"]`,
FileMany: ``,
RelOne: `["84nmscqy84lsi1t"]`,
RelMany: `oap640cot4yru2s`,
},
},
{
"84nmscqy84lsi1t",
expectation{
SelectOne: `["optionB"]`,
SelectMany: `optionC`,
FileOne: `["test_d61b33QdDU.txt"]`,
FileMany: `test_tC1Yc87DfC.txt`,
RelOne: `[]`,
RelMany: `oap640cot4yru2s`,
},
},
}
for _, s := range scenarios {
result := new(expectation)
err := app.Dao().DB().Select(
"select_one",
"select_many",
"file_one",
"file_many",
"rel_one",
"rel_many",
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
if err != nil {
t.Errorf("[%s] Failed to load record: %v", s.recordId, err)
continue
}
encodedResult, err := json.Marshal(result)
if err != nil {
t.Errorf("[%s] Failed to encode result: %v", s.recordId, err)
continue
}
encodedExpectation, err := json.Marshal(s.expected)
if err != nil {
t.Errorf("[%s] Failed to encode expectation: %v", s.recordId, err)
continue
}
if !bytes.EqualFold(encodedExpectation, encodedResult) {
t.Errorf("[%s] Expected \n%s, \ngot \n%s", s.recordId, encodedExpectation, encodedResult)
}
}
}
+6 -6
View File
@@ -748,13 +748,13 @@ func TestDeleteRecord(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
expectedRelManyPart := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
}
expectedRelOneJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
expectedRelOnePart := "SELECT DISTINCT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`="
if !strings.Contains(joinedQueries, expectedRelOnePart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
_, err := dao.DB().Delete(tableName, expr).Execute()
_, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
return err
}
+34 -4
View File
@@ -21,8 +21,8 @@ func (dao *Dao) HasTable(tableName string) bool {
return err == nil && exists
}
// GetTableColumns returns all column names of a single table by its name.
func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
// TableColumns returns all column names of a single table by its name.
func (dao *Dao) TableColumns(tableName string) ([]string, error) {
columns := []string{}
err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
@@ -32,8 +32,8 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
return columns, err
}
// GetTableInfo returns the `table_info` pragma result for the specified table.
func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
// TableInfo returns the `table_info` pragma result for the specified table.
func (dao *Dao) TableInfo(tableName string) ([]*models.TableInfoRow, error) {
info := []*models.TableInfoRow{}
err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
@@ -52,6 +52,36 @@ func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
return info, nil
}
// TableIndexes returns a name grouped map with all non empty index of the specified table.
//
// Note: This method doesn't return an error on nonexisting table.
func (dao *Dao) TableIndexes(tableName string) (map[string]string, error) {
indexes := []struct {
Name string
Sql string
}{}
err := dao.DB().Select("name", "sql").
From("sqlite_master").
AndWhere(dbx.NewExp("sql is not null")).
AndWhere(dbx.HashExp{
"type": "index",
"tbl_name": tableName,
}).
All(&indexes)
if err != nil {
return nil, err
}
result := make(map[string]string, len(indexes))
for _, idx := range indexes {
result[idx.Name] = idx.Sql
}
return result, nil
}
// DeleteTable drops the specified table.
//
// This method is a no-op if a table with the provided name doesn't exist.
+46 -4
View File
@@ -35,7 +35,7 @@ func TestHasTable(t *testing.T) {
}
}
func TestGetTableColumns(t *testing.T) {
func TestTableColumns(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -48,7 +48,7 @@ func TestGetTableColumns(t *testing.T) {
}
for i, s := range scenarios {
columns, _ := app.Dao().GetTableColumns(s.tableName)
columns, _ := app.Dao().TableColumns(s.tableName)
if len(columns) != len(s.expected) {
t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
@@ -63,7 +63,7 @@ func TestGetTableColumns(t *testing.T) {
}
}
func TestGetTableInfo(t *testing.T) {
func TestTableInfo(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -80,7 +80,7 @@ func TestGetTableInfo(t *testing.T) {
}
for i, s := range scenarios {
rows, _ := app.Dao().GetTableInfo(s.tableName)
rows, _ := app.Dao().TableInfo(s.tableName)
raw, _ := json.Marshal(rows)
rawStr := string(raw)
@@ -139,3 +139,45 @@ func TestVacuum(t *testing.T) {
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
}
}
func TestTableIndexes(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
table string
expectError bool
expectIndexes []string
}{
{
"missing",
false,
nil,
},
{
"demo2",
false,
[]string{"idx_demo2_created", "idx_unique_demo2_title", "idx_demo2_active"},
},
}
for _, s := range scenarios {
result, err := app.Dao().TableIndexes(s.table)
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v", s.table, s.expectError, hasErr)
}
if len(s.expectIndexes) != len(result) {
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.table, len(s.expectIndexes), len(result), result)
continue
}
for _, name := range s.expectIndexes {
if result[name] == "" {
t.Errorf("[%s] Missing index %q in \n%v", s.table, name, result)
}
}
}
}
+18 -14
View File
@@ -63,7 +63,11 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
// fetch the view table info to ensure that the view was created
// because missing tables or columns won't return an error
if _, err := txDao.GetTableInfo(name); err != nil {
if _, err := txDao.TableInfo(name); err != nil {
// manually cleanup previously created view in case the func
// is called in a nested transaction and the error is discarded
txDao.DeleteView(name)
return err
}
@@ -95,7 +99,7 @@ func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
defer txDao.DeleteView(tempView)
// extract the generated view table info
info, err := txDao.GetTableInfo(tempView)
info, err := txDao.TableInfo(tempView)
if err != nil {
return err
}
@@ -189,15 +193,18 @@ func (dao *Dao) FindRecordByViewFile(
record := &models.Record{}
err = dao.RecordQuery(qf.collection).
InnerJoin(fmt.Sprintf(
// note: the case is used to normalize the value access
query := dao.RecordQuery(qf.collection).Limit(1)
if opt, ok := qf.original.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{cleanFieldName: filename})
} else {
query.InnerJoin(fmt.Sprintf(
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{_je_file}}`,
cleanFieldName, cleanFieldName, cleanFieldName,
), dbx.HashExp{"_je_file.value": filename}).
Limit(1).
One(record)
if err != nil {
), dbx.HashExp{"_je_file.value": filename})
}
if err := query.One(record); err != nil {
return nil, err
}
@@ -272,16 +279,13 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
var fieldName string
var collection *models.Collection
var isMainTableField bool
if len(parts) == 2 {
fieldName = parts[1]
collection = collections[parts[0]]
isMainTableField = parts[0] == mainTable.alias
} else {
fieldName = parts[0]
collection = collections[mainTable.alias]
isMainTableField = true
}
// fallback to the default field if the found column is not from a collection schema
@@ -316,8 +320,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
continue
}
if fieldName == schema.FieldNameId && !isMainTableField {
// convert to relation since it is a direct id reference to non-maintable collection
if fieldName == schema.FieldNameId {
// convert to relation since it is a direct id reference
result[col.alias] = &queryField{
field: &schema.SchemaField{
Name: col.alias,
+47 -4
View File
@@ -5,12 +5,33 @@ import (
"fmt"
"testing"
"github.com/pocketbase/dbx"
"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/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func ensureNoTempViews(app core.App, t *testing.T) {
var total int
err := app.Dao().DB().Select("count(*)").
From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "view"}).
AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)).
Limit(1).
Row(&total)
if err != nil {
t.Fatalf("Failed to check for temp views: %v", err)
}
if total > 0 {
t.Fatalf("Expected all temp views to be deleted, got %d", total)
}
}
func TestDeleteView(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -34,6 +55,8 @@ func TestDeleteView(t *testing.T) {
t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
}
}
ensureNoTempViews(app, t)
}
func TestSaveView(t *testing.T) {
@@ -78,7 +101,7 @@ func TestSaveView(t *testing.T) {
{
"missing table",
"123Test",
"select * from missing",
"select id from missing",
true,
nil,
},
@@ -136,7 +159,7 @@ func TestSaveView(t *testing.T) {
continue
}
infoRows, err := app.Dao().GetTableInfo(s.viewName)
infoRows, err := app.Dao().TableInfo(s.viewName)
if err != nil {
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
continue
@@ -153,6 +176,24 @@ func TestSaveView(t *testing.T) {
}
}
}
ensureNoTempViews(app, t)
}
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
_, err := txDao.CreateViewSchema("select id from missing")
if err == nil {
t.Fatal("Expected error, got nil")
}
return nil
})
ensureNoTempViews(app, t)
}
func TestCreateViewSchema(t *testing.T) {
@@ -179,7 +220,7 @@ func TestCreateViewSchema(t *testing.T) {
},
{
"missing table",
"select * from missing",
"select id from missing",
true,
nil,
},
@@ -358,7 +399,7 @@ func TestCreateViewSchema(t *testing.T) {
`,
false,
map[string]string{
"id2": schema.FieldTypeJson,
"id2": schema.FieldTypeRelation,
"text_alias": schema.FieldTypeText,
"url_alias": schema.FieldTypeUrl,
"bool_alias": schema.FieldTypeBool,
@@ -403,6 +444,8 @@ func TestCreateViewSchema(t *testing.T) {
}
}
}
ensureNoTempViews(app, t)
}
func TestFindRecordByViewFile(t *testing.T) {
+87
View File
@@ -0,0 +1,87 @@
package forms
import (
"regexp"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/core"
)
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
//
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct {
app core.App
// ClientId is the identifier of your app (aka. Service ID).
ClientId string `form:"clientId" json:"clientId"`
// TeamId is a 10-character string associated with your developer account
// (usually could be found next to your name in the Apple Developer site).
TeamId string `form:"teamId" json:"teamId"`
// KeyId is a 10-character key identifier generated for the "Sign in with Apple"
// private key associated with your developer account.
KeyId string `form:"keyId" json:"keyId"`
// PrivateKey is the private key associated to your app.
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"`
// Duration specifies how long the generated JWT token should be considered valid.
// The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"`
}
// NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
// config created from the provided [core.App] instances.
func NewAppleClientSecretCreate(app core.App) *AppleClientSecretCreate {
form := &AppleClientSecretCreate{
app: app,
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AppleClientSecretCreate) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required),
validation.Field(&form.TeamId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.KeyId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.PrivateKey, validation.Required, validation.Match(privateKeyRegex)),
validation.Field(&form.Duration, validation.Required, validation.Min(1), validation.Max(15777000)),
)
}
// Submit validates the form and returns a new Apple Client Secret JWT.
func (form *AppleClientSecretCreate) Submit() (string, error) {
if err := form.Validate(); err != nil {
return "", err
}
signKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(strings.TrimSpace(form.PrivateKey)))
if err != nil {
return "", err
}
now := time.Now()
claims := &jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
Subject: form.ClientId,
Issuer: form.TeamId,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(form.Duration) * time.Second)),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = form.KeyId
return token.SignedString(signKey)
}
+117
View File
@@ -0,0 +1,117 @@
package forms_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedKey,
},
)
scenarios := []struct {
name string
formData map[string]any
expectError bool
}{
{
"empty data",
map[string]any{},
true,
},
{
"invalid data",
map[string]any{
"clientId": "",
"teamId": "123456789",
"keyId": "123456789",
"privateKey": "-----BEGIN PRIVATE KEY----- invalid -----END PRIVATE KEY-----",
"duration": -1,
},
true,
},
{
"valid data",
map[string]any{
"clientId": "123",
"teamId": "1234567890",
"keyId": "1234567891",
"privateKey": string(privatePem),
"duration": 1,
},
false,
},
}
for _, s := range scenarios {
form := forms.NewAppleClientSecretCreate(app)
rawData, marshalErr := json.Marshal(s.formData)
if marshalErr != nil {
t.Errorf("[%s] Failed to marshalize the scenario data: %v", s.name, marshalErr)
continue
}
// load data
loadErr := json.Unmarshal(rawData, form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
secret, err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
if hasErr {
continue
}
if secret == "" {
t.Errorf("[%s] Expected non-empty secret", s.name)
}
claims := jwt.MapClaims{}
token, _, err := jwt.NewParser().ParseUnverified(secret, claims)
if err != nil {
t.Errorf("[%s] Failed to parse token: %v", s.name, err)
}
if alg := token.Header["alg"]; alg != "ES256" {
t.Errorf("[%s] Expected %q alg header, got %q", s.name, "ES256", alg)
}
if kid := token.Header["kid"]; kid != form.KeyId {
t.Errorf("[%s] Expected %q kid header, got %q", s.name, form.KeyId, kid)
}
}
}
+61 -18
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
"regexp"
"strings"
"strconv"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
@@ -13,6 +13,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/types"
@@ -26,17 +27,18 @@ type CollectionUpsert struct {
dao *daos.Dao
collection *models.Collection
Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
ListRule *string `form:"listRule" json:"listRule"`
ViewRule *string `form:"viewRule" json:"viewRule"`
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
Options types.JsonMap `form:"options" json:"options"`
Id string `form:"id" json:"id"`
Type string `form:"type" json:"type"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
Indexes types.JsonArray[string] `form:"indexes" json:"indexes"`
ListRule *string `form:"listRule" json:"listRule"`
ViewRule *string `form:"viewRule" json:"viewRule"`
CreateRule *string `form:"createRule" json:"createRule"`
UpdateRule *string `form:"updateRule" json:"updateRule"`
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
Options types.JsonMap `form:"options" json:"options"`
}
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
@@ -57,6 +59,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
form.Type = form.collection.Type
form.Name = form.collection.Name
form.System = form.collection.System
form.Indexes = form.collection.Indexes
form.ListRule = form.collection.ListRule
form.ViewRule = form.collection.ViewRule
form.CreateRule = form.collection.CreateRule
@@ -155,6 +158,7 @@ func (form *CollectionUpsert) Validate() error {
validation.When(isView, validation.Nil),
validation.By(form.checkRule),
),
validation.Field(&form.Indexes, validation.By(form.checkIndexes)),
validation.Field(&form.Options, validation.By(form.checkOptions)),
)
}
@@ -172,11 +176,6 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
}
// ensure that there is no existing table name with the same name
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
return nil
}
@@ -386,6 +385,44 @@ func (form *CollectionUpsert) checkRule(value any) error {
return nil
}
func (form *CollectionUpsert) checkIndexes(value any) error {
v, _ := value.(types.JsonArray[string])
if form.Type == models.CollectionTypeView && len(v) > 0 {
return validation.NewError(
"validation_indexes_not_supported",
"The collection doesn't support indexes.",
)
}
for i, rawIndex := range v {
parsed := dbutils.ParseIndex(rawIndex)
if !parsed.IsValid() {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_invalid_index_expression",
"Invalid CREATE INDEX expression.",
),
}
}
// note: we don't check the index table because it is always
// overwritten by the daos.SyncRecordTableSchema to allow
// easier partial modifications (eg. changing only the collection name).
// if !strings.EqualFold(parsed.TableName, form.Name) {
// return validation.Errors{
// strconv.Itoa(i): validation.NewError(
// "validation_invalid_index_table",
// fmt.Sprintf("The index table must be the same as the collection name."),
// ),
// }
// }
}
return nil
}
func (form *CollectionUpsert) checkOptions(value any) error {
v, _ := value.(types.JsonMap)
@@ -473,9 +510,15 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Col
form.collection.Name = form.Name
}
// view schema is autogenerated on save
// view schema is autogenerated on save and cannot have indexes
if !form.collection.IsView() {
form.collection.Schema = form.Schema
// normalize indexes format
form.collection.Indexes = make(types.JsonArray[string], len(form.Indexes))
for i, rawIdx := range form.Indexes {
form.collection.Indexes[i] = dbutils.ParseIndex(rawIdx).Build()
}
}
form.collection.ListRule = form.ListRule
+44 -10
View File
@@ -10,6 +10,7 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
@@ -114,9 +115,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": "missing = '123'",
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'"
"deleteRule": "missing = '123'",
"indexes": ["create index '' on '' ()"]
}`,
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
},
{
"create failure - existing name",
@@ -234,7 +236,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": "test1='123' && emailVisibility = true",
"createRule": "test1='123' && email != ''",
"updateRule": "test1='123' && username != ''",
"deleteRule": "test1='123' && id != ''"
"deleteRule": "test1='123' && id != ''",
"indexes": ["create index idx_test_new on anything (test1)"]
}`,
[]string{},
},
@@ -245,9 +248,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"schema": [
{"id":"a123456","name":"test1","type":"url"},
{"id":"b123456","name":"test2","type":"bool"}
]
],
"indexes": ["create index idx_test_new on test_new (test1)", "invalid"]
}`,
[]string{"schema"},
[]string{"schema", "indexes"},
},
{
"update success - rename fields to existing field names (aka. reusing field names)",
@@ -326,9 +330,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"createRule": "missing = '123'",
"updateRule": "missing = '123'",
"deleteRule": "missing = '123'",
"options": {"test": 123}
"options": {"test": 123},
"indexes": ["create index '' from demo2 on (id)"]
}`,
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
},
{
"update success - update all fields",
@@ -344,7 +349,11 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"createRule": "test='123' && email != ''",
"updateRule": "test='123' && username != ''",
"deleteRule": "test='123' && id != ''",
"options": {"minPasswordLength": 10}
"options": {"minPasswordLength": 10},
"indexes": [
"create index idx_clients_test1 on anything (id, email, test)",
"create unique index idx_clients_test2 on clients (id, username, email)"
]
}`,
[]string{},
},
@@ -371,7 +380,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null
"deleteRule": null,
"indexes": []
}`,
[]string{},
},
@@ -403,12 +413,14 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
],
"options": {
"query": "select id, email from users; drop table _admins;"
}
},
"indexes": ["create index idx_test_view on upsert_view (id)"]
}`,
[]string{
"listRule",
"viewRule",
"options",
"indexes", // views don't have indexes
},
},
{
@@ -592,6 +604,28 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
continue
}
}
// check indexes (if any)
allIndexes, _ := app.Dao().TableIndexes(form.Name)
for _, formIdx := range form.Indexes {
parsed := dbutils.ParseIndex(formIdx)
parsed.TableName = form.Name
normalizedIdx := parsed.Build()
var exists bool
for _, idx := range allIndexes {
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
exists = true
continue
}
}
if !exists {
t.Errorf(
"[%s] Missing index %s \nin \n%v", s.testName, normalizedIdx, allIndexes)
continue
}
}
}
}
+24 -2
View File
@@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 5,
"OnModelBeforeDelete": 7,
},
},
{
@@ -215,7 +215,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
"collections": [
{
"id":"sz5l5z67tg7gku0",
"name":"demo2",
"name":"demo2_rename",
"schema":[
{
"id":"_2hlxbmp",
@@ -347,6 +347,28 @@ func TestCollectionsImportSubmit(t *testing.T) {
"OnModelAfterDelete": totalCollections - 2,
},
},
{
name: "lazy system table name error",
jsonData: `{
"collections": [
{
"name": "_admins",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
},
{
name: "lazy view evaluation",
jsonData: `{
+15 -6
View File
@@ -1,8 +1,10 @@
package forms
import (
"context"
"errors"
"fmt"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
@@ -16,9 +18,10 @@ import (
// RecordOAuth2LoginData defines the OA
type RecordOAuth2LoginData struct {
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
ExternalAuth *models.ExternalAuth
Record *models.Record
OAuth2User *auth.AuthUser
ProviderClient auth.Provider
}
// BeforeOAuth2RecordCreateFunc defines a callback function that will
@@ -127,6 +130,11 @@ func (form *RecordOAuth2Login) Submit(
return nil, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
provider.SetContext(ctx)
// load provider configuration
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
if err := providerConfig.SetupProvider(provider); err != nil {
@@ -169,9 +177,10 @@ func (form *RecordOAuth2Login) Submit(
}
interceptorData := &RecordOAuth2LoginData{
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
ExternalAuth: rel,
Record: authRecord,
OAuth2User: authUser,
ProviderClient: provider,
}
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
+67 -8
View File
@@ -740,14 +740,46 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
form.record.MarkAsNew()
}
// persist the record model
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
return fmt.Errorf("failed to save the record: %w", saveErr)
}
dao := form.dao.Clone()
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return fmt.Errorf("failed to process the uploaded files: %w", err)
//
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hooks
// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
// but BEFORE the actual record db persistence
// ---
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
if err := form.dao.BeforeCreateFunc(eventDao, m); err != nil {
return err
}
if m.GetId() == form.record.GetId() {
return form.processFilesToUpload()
}
return nil
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
if err := form.dao.BeforeUpdateFunc(eventDao, m); err != nil {
return err
}
if m.GetId() == form.record.GetId() {
return form.processFilesToUpload()
}
return nil
}
// ---
// persist the record model
if err := dao.SaveRecord(form.record); err != nil {
preparedErr := form.prepareError(err)
if _, ok := preparedErr.(validation.Errors); ok {
return preparedErr
}
return fmt.Errorf("failed to save the record: %w", err)
}
// delete old files (if any)
@@ -768,7 +800,7 @@ func (form *RecordUpsert) processFilesToUpload() error {
}
if !form.record.HasId() {
return errors.New("the record is not persisted yet")
return errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
@@ -816,7 +848,7 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
}
if !form.record.HasId() {
return filenames, errors.New("the record doesn't have a unique ID")
return filenames, errors.New("the record doesn't have an id")
}
fs, err := form.app.NewFilesystem()
@@ -849,3 +881,30 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
return filenames, nil
}
// prepareError parses the provided error and tries to return
// user-friendly validation error(s).
func (form *RecordUpsert) prepareError(err error) error {
msg := strings.ToLower(err.Error())
validationErrs := validation.Errors{}
// check for unique constraint failure
if strings.Contains(msg, "unique constraint failed") {
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
c := form.record.Collection()
for _, f := range c.Schema.Fields() {
// blank space to unify multi-columns lookup
if strings.Contains(msg+" ", fmt.Sprintf("%s.%s ", strings.ToLower(c.Name), f.Name)) {
validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
}
}
}
if len(validationErrs) > 0 {
return validationErrs
}
return err
}
+48
View File
@@ -1037,3 +1037,51 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
t.Fatalf("Expected file_many to be 5, got %v", fileMany)
}
}
func TestRecordUpsertUploadFailure(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
// create with invalid file
{
new := models.NewRecord(collection)
new.Id = "123456789012341"
form := forms.NewRecordUpsert(app, new)
form.LoadData(map[string]any{"title": "new_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatal("Expected error, got nil")
}
if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
t.Fatalf("Expected the inserted record to be deleted, found \n%v", r.PublicExport())
}
}
// update with invalid file
{
record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordUpsert(app, record)
form.LoadData(map[string]any{"title": "update_test"})
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
if err := form.Submit(); err == nil {
t.Fatal("Expected error, got nil")
}
if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
t.Fatalf("Expected the record changes to be reverted, got \n%v", r.PublicExport())
}
}
}
-11
View File
@@ -86,17 +86,6 @@ func (validator *RecordDataValidator) Validate(data map[string]any) error {
errs[key] = err
continue
}
// check unique constraint
if field.Unique && !validator.dao.IsRecordValueUnique(
validator.record.Collection().Id,
key,
value,
validator.record.GetId(),
) {
errs[key] = validation.NewError("validation_not_unique", "Value must be unique")
continue
}
}
if len(errs) == 0 {
-100
View File
@@ -99,16 +99,6 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(text) check unique constraint",
map[string]any{
"field1": "test",
"field2": "test",
"field3": "test",
},
nil,
[]string{"field3"},
},
{
"(text) check min constraint",
map[string]any{
@@ -225,16 +215,6 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(number) check unique constraint",
map[string]any{
"field1": 123,
"field2": 123,
"field3": 123,
},
nil,
[]string{"field3"},
},
{
"(number) check min constraint",
map[string]any{
@@ -345,16 +325,6 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(bool) check unique constraint",
map[string]any{
"field1": true,
"field2": true,
"field3": true,
},
nil,
[]string{"field3"},
},
{
"(bool) valid data (only required)",
map[string]any{
@@ -441,16 +411,6 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
nil,
[]string{"field1", "field2", "field3"},
},
{
"(email) check unique constraint",
map[string]any{
"field1": "test@example.com",
"field2": "test@test.com",
"field3": "test@example.com",
},
nil,
[]string{"field3"},
},
{
"(email) check ExceptDomains constraint",
map[string]any{
@@ -557,16 +517,6 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
nil,
[]string{"field1", "field3"},
},
{
"(url) check unique constraint",
map[string]any{
"field1": "http://example.com",
"field2": "http://test.com",
"field3": "http://example.com",
},
nil,
[]string{"field3"},
},
{
"(url) check ExceptDomains constraint",
map[string]any{
@@ -695,16 +645,6 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(date) check unique constraint",
map[string]any{
"field1": "2029-01-01 01:01:01.123",
"field2": "2029-01-01 01:01:01.123",
"field3": "2029-01-01 01:01:01.123",
},
nil,
[]string{"field3"},
},
{
"(date) check min date constraint",
map[string]any{
@@ -827,26 +767,6 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
nil,
[]string{},
},
{
"(select) check unique constraint",
map[string]any{
"field1": "a",
"field2": "b",
"field3": []string{"a", "b", "c"},
},
nil,
[]string{"field3"},
},
{
"(select) check unique constraint - same elements but different order",
map[string]any{
"field1": "a",
"field2": "b",
"field3": []string{"a", "c", "b"},
},
nil,
[]string{},
},
{
"(select) check Values constraint",
map[string]any{
@@ -977,16 +897,6 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
nil,
[]string{"field2"},
},
{
"(json) check unique constraint",
map[string]any{
"field1": `{"test":123}`,
"field2": `{"test":123}`,
"field3": map[string]any{"test": 123},
},
nil,
[]string{"field3"},
},
{
"(json) check json text invalid obj, array and number normalizations",
map[string]any{
@@ -1268,16 +1178,6 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
nil,
[]string{"field3"},
},
{
"check unique constraint",
map[string]any{
"field1": relId1,
"field2": relId2,
"field3": []string{relId1, relId2, relId3, relId3}, // repeating values are collapsed
},
nil,
[]string{"field3"},
},
{
"check nonexisting collection id",
map[string]any{
+40 -40
View File
@@ -4,13 +4,16 @@ go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/aws/aws-sdk-go v1.44.209
github.com/aws/aws-sdk-go-v2 v1.17.7
github.com/aws/aws-sdk-go-v2/config v1.18.19
github.com/aws/aws-sdk-go-v2/credentials v1.13.18
github.com/aws/aws-sdk-go-v2/service/s3 v1.31.1
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.4.0
github.com/domodwyer/mailyak/v3 v3.5.0
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
github.com/fatih/color v1.14.1
github.com/gabriel-vasile/mimetype v1.4.1
github.com/fatih/color v1.15.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/ganigeorgiev/fexpr v0.3.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v4 v4.5.0
@@ -19,67 +22,64 @@ require (
github.com/pocketbase/dbx v1.10.0
github.com/spf13/cast v1.5.0
github.com/spf13/cobra v1.6.1
gocloud.dev v0.28.0
golang.org/x/crypto v0.6.0
golang.org/x/net v0.7.0
golang.org/x/oauth2 v0.5.0
modernc.org/sqlite v1.21.0
gocloud.dev v0.29.0
golang.org/x/crypto v0.7.0
golang.org/x/net v0.8.0
golang.org/x/oauth2 v0.6.0
modernc.org/sqlite v1.21.1
)
require (
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/aws/aws-sdk-go-v2 v1.17.5 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.44.234 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.55 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.60 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.5.0 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/image v0.6.0 // indirect
golang.org/x/mod v0.9.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.6.0 // indirect
golang.org/x/tools v0.7.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.110.0 // indirect
google.golang.org/api v0.114.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect
google.golang.org/grpc v1.53.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633 // indirect
google.golang.org/grpc v1.54.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.3 // indirect
+357 -235
View File
File diff suppressed because it is too large Load Diff
+13 -45
View File
@@ -19,12 +19,7 @@ const Layout = `
font-family: Source Sans Pro, sans-serif, emoji;
}
body {
padding: 30px 20px;
}
p {
display: block;
margin: 10px 0;
font-family: Source Sans Pro, sans-serif, emoji;
padding: 20px 30px;
}
strong {
font-weight: bold;
@@ -32,6 +27,11 @@ const Layout = `
em, i {
font-style: italic;
}
p {
display: block;
margin: 10px 0;
font-family: inherit;
}
small {
font-size: 12px;
line-height: 16px;
@@ -58,54 +58,22 @@ const Layout = `
color: #fff !important;
background: #16161a !important;
text-decoration: none !important;
line-height: 45px;
width: 100%;
max-width: 100%;
line-height: 40px;
width: auto;
min-width: 150px;
text-align: center;
padding: 0 30px;
margin: 10px 0;
padding: 0 20px;
margin: 5px 0;
font-family: Source Sans Pro, sans-serif, emoji;;
font-size: 14px;
font-weight: bold;
border-radius: 3px;
border-radius: 6px;
box-sizing: border-box;
}
.wrapper {
display: block;
width: 460px;
max-width: 100%;
margin: auto;
font-family: inherit;
}
.content {
display: block;
width: 100%;
padding: 10px 20px;
font-family: inherit;
box-sizing: border-box;
background: #fff;
border-radius: 10px;
-webkit-box-shadow: 0px 2px 30px 0px rgb(0,0,0,0.05);
-moz-box-shadow: 0px 2px 30px 0px rgb(0,0,0,0.05);
box-shadow: 0px 2px 30px 0px rgb(0,0,0,0.05);
}
.footer {
display: block;
width: 100%;
text-align: center;
margin: 10px 0;
color: #666f75;
font-size: 11px;
font-family: inherit;
}
</style>
</head>
<body>
<div class="wrapper">
<div class="content">
{{template "content" .}}
</div>
</div>
{{template "content" .}}
</body>
</html>
`
+7 -6
View File
@@ -42,8 +42,8 @@ func init() {
[[tokenKey]] TEXT UNIQUE NOT NULL,
[[passwordHash]] TEXT NOT NULL,
[[lastResetSentAt]] TEXT DEFAULT "" NOT NULL,
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
CREATE TABLE {{_collections}} (
@@ -52,14 +52,15 @@ func init() {
[[type]] TEXT DEFAULT "base" NOT NULL,
[[name]] TEXT UNIQUE NOT NULL,
[[schema]] JSON DEFAULT "[]" NOT NULL,
[[indexes]] JSON DEFAULT "[]" NOT NULL,
[[listRule]] TEXT DEFAULT NULL,
[[viewRule]] TEXT DEFAULT NULL,
[[createRule]] TEXT DEFAULT NULL,
[[updateRule]] TEXT DEFAULT NULL,
[[deleteRule]] TEXT DEFAULT NULL,
[[options]] JSON DEFAULT "{}" NOT NULL,
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
CREATE TABLE {{_params}} (
@@ -76,8 +77,8 @@ func init() {
[[recordId]] TEXT NOT NULL,
[[provider]] TEXT NOT NULL,
[[providerId]] TEXT NOT NULL,
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL,
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
---
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
);
@@ -0,0 +1,104 @@
package migrations
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
// Normalizes old single and multiple values of MultiValuer fields (file, select, relation).
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
collections := []*models.Collection{}
if err := dao.CollectionQuery().All(&collections); err != nil {
return err
}
for _, c := range collections {
if c.IsView() {
// skip view collections
continue
}
for _, f := range c.Schema.Fields() {
opt, ok := f.Options.(schema.MultiValuer)
if !ok {
continue
}
var updateQuery *dbx.Query
if opt.IsMultiple() {
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '') = ''
THEN '[]'
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN [[%s]]
ELSE json_array([[%s]])
END
)
END
)`,
c.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
))
} else {
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
`UPDATE {{%s}} set [[%s]] = (
CASE
WHEN COALESCE([[%s]], '[]') = '[]'
THEN ''
ELSE (
CASE
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
ELSE [[%s]]
END
)
END
)`,
c.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
f.Name,
))
}
if _, err := updateQuery.Execute(); err != nil {
return err
}
}
}
// trigger view query update after the records normalization
// (ignore save error in case of invalid query to allow users to change it from the UI)
for _, c := range collections {
if !c.IsView() {
continue
}
dao.SaveCollection(c)
}
return nil
}, func(db dbx.Builder) error {
return nil
})
}
+136
View File
@@ -0,0 +1,136 @@
package migrations
import (
"fmt"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/list"
)
// Adds _collections indexes column (if not already).
//
// Note: This migration will be deleted once schema.SchemaField.Unuique is removed.
func init() {
AppMigrations.Register(func(db dbx.Builder) error {
dao := daos.New(db)
// cleanup failed remaining/"dangling" temp views to prevent
// errors during the indexes upsert
// ---
tempViews := []string{}
viewsErr := db.Select("name").
From("sqlite_schema").
AndWhere(dbx.HashExp{"type": "view"}).
AndWhere(dbx.NewExp(`[[name]] LIKE '\_temp\_%' ESCAPE '\'`)).
Column(&tempViews)
if viewsErr != nil {
return viewsErr
}
for _, name := range tempViews {
if err := dao.DeleteView(name); err != nil {
return err
}
}
// ---
cols, err := dao.TableColumns("_collections")
if err != nil {
return err
}
for _, col := range cols {
if col == "indexes" {
return nil // already existing (probably via the init migration)
}
}
if _, err := db.AddColumn("_collections", "indexes", `JSON DEFAULT "[]" NOT NULL`).Execute(); err != nil {
return err
}
collections := []*models.Collection{}
if err := dao.CollectionQuery().AndWhere(dbx.NewExp("type != 'view'")).All(&collections); err != nil {
return err
}
type indexInfo struct {
Sql string `db:"sql"`
IndexName string `db:"name"`
TableName string `db:"tbl_name"`
}
indexesQuery := db.NewQuery(`SELECT * FROM sqlite_master WHERE type = "index" and sql is not null`)
rawIndexes := []indexInfo{}
if err := indexesQuery.All(&rawIndexes); err != nil {
return err
}
indexesByTableName := map[string][]indexInfo{}
for _, idx := range rawIndexes {
indexesByTableName[idx.TableName] = append(indexesByTableName[idx.TableName], idx)
}
for _, c := range collections {
c.Indexes = nil // reset
excludeIndexes := []string{
"_" + c.Id + "_email_idx",
"_" + c.Id + "_username_idx",
"_" + c.Id + "_tokenKey_idx",
}
// convert custom indexes into the related collections
for _, idx := range indexesByTableName[c.Name] {
if strings.Contains(idx.IndexName, "sqlite_autoindex_") ||
list.ExistInSlice(idx.IndexName, excludeIndexes) {
continue
}
// drop old index (it will be recreated with the collection)
if _, err := db.DropIndex(idx.TableName, idx.IndexName).Execute(); err != nil {
return err
}
c.Indexes = append(c.Indexes, idx.Sql)
}
// convert unique fields to indexes
FieldsLoop:
for _, f := range c.Schema.Fields() {
if !f.Unique {
continue
}
for _, idx := range indexesByTableName[c.Name] {
parsed := dbutils.ParseIndex(idx.Sql)
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, f.Name) {
continue FieldsLoop // already added
}
}
c.Indexes = append(c.Indexes, fmt.Sprintf(
`CREATE UNIQUE INDEX "idx_unique_%s" on "%s" ("%s")`,
f.Id,
c.Name,
f.Name,
))
}
if len(c.Indexes) > 0 {
if err := dao.SaveCollection(c); err != nil {
return err
}
}
}
return nil
}, func(db dbx.Builder) error {
_, err := db.DropColumn("_collections", "indexes").Execute()
return err
})
}
+2 -2
View File
@@ -20,8 +20,8 @@ func init() {
[[referer]] TEXT DEFAULT "" NOT NULL,
[[userAgent]] TEXT DEFAULT "" NOT NULL,
[[meta]] JSON DEFAULT "{}" NOT NULL,
[[created]] TEXT DEFAULT "" NOT NULL,
[[updated]] TEXT DEFAULT "" NOT NULL
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
CREATE INDEX _request_status_idx on {{_requests}} ([[status]]);
@@ -0,0 +1,18 @@
package logs
import (
"github.com/pocketbase/dbx"
)
// This migration normalizes the request logs method to UPPERCASE (eg. "get" => "GET").
func init() {
LogsMigrations.Register(func(db dbx.Builder) error {
_, err := db.NewQuery("UPDATE {{_requests}} SET method=UPPER(method)").Execute()
return err
}, func(db dbx.Builder) error {
_, err := db.NewQuery("UPDATE {{_requests}} SET method=LOWER(method)").Execute()
return err
})
}
+1 -1
View File
@@ -48,7 +48,7 @@ func (m *Admin) SetPassword(password string) error {
}
// hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
+5 -4
View File
@@ -23,10 +23,11 @@ const (
type Collection struct {
BaseModel
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
System bool `db:"system" json:"system"`
Schema schema.Schema `db:"schema" json:"schema"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
System bool `db:"system" json:"system"`
Schema schema.Schema `db:"schema" json:"schema"`
Indexes types.JsonArray[string] `db:"indexes" json:"indexes"`
// rules
ListRule *string `db:"listRule" json:"listRule"`
+5 -5
View File
@@ -75,22 +75,22 @@ func TestCollectionMarshalJSON(t *testing.T) {
{
"no type",
models.Collection{Name: "test"},
`{"id":"","created":"","updated":"","name":"test","type":"","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
`{"id":"","created":"","updated":"","name":"test","type":"","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
},
{
"unknown type + non empty options",
models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
`{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}, Indexes: types.JsonArray[string]{"idx_test"}},
`{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"indexes":["idx_test"],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
},
{
"base type + non empty options",
models.Collection{Name: "test", Type: models.CollectionTypeBase, ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
`{"id":"","created":"","updated":"","name":"test","type":"base","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
`{"id":"","created":"","updated":"","name":"test","type":"base","system":false,"schema":[],"indexes":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
},
{
"auth type + non empty options",
models.Collection{BaseModel: models.BaseModel{Id: "test"}, Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "allowOAuth2Auth": true, "minPasswordLength": 4}},
`{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
`{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
},
}
+5 -5
View File
@@ -630,16 +630,16 @@ func (m *Record) getNormalizeDataValueForDB(key string) any {
switch ids := val.(type) {
case []string:
// encode string slice
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
return append(types.JsonArray[string]{}, ids...)
case []int:
// encode int slice
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
return append(types.JsonArray[int]{}, ids...)
case []float64:
// encode float64 slice
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
return append(types.JsonArray[float64]{}, ids...)
case []any:
// encode interface slice
return append(types.JsonArray{}, ids...)
return append(types.JsonArray[any]{}, ids...)
default:
// no changes
return val
@@ -864,7 +864,7 @@ func (m *Record) SetPassword(password string) error {
}
// hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
+1
View File
@@ -12,6 +12,7 @@ type RequestData struct {
Method string `json:"method"`
Query map[string]any `json:"query"`
Data map[string]any `json:"data"`
Headers map[string]any `json:"headers"`
AuthRecord *Record `json:"authRecord"`
Admin *Admin `json:"admin"`
}
+37 -6
View File
@@ -131,8 +131,12 @@ type SchemaField struct {
Name string `form:"name" json:"name"`
Type string `form:"type" json:"type"`
Required bool `form:"required" json:"required"`
Unique bool `form:"unique" json:"unique"`
Options any `form:"options" json:"options"`
// Deprecated: This field is no-op and will be removed in future versions.
// Please use the collection.Indexes field to define a unique constraint.
Unique bool `form:"unique" json:"unique"`
Options any `form:"options" json:"options"`
}
// ColDefinition returns the field db column type definition as string.
@@ -321,7 +325,7 @@ func (f *SchemaField) PrepareValue(value any) any {
val := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*SelectOptions)
if options.MaxSelect <= 1 {
if !options.IsMultiple() {
if len(val) > 0 {
return val[len(val)-1] // the last selected
}
@@ -333,7 +337,7 @@ func (f *SchemaField) PrepareValue(value any) any {
val := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*FileOptions)
if options.MaxSelect <= 1 {
if !options.IsMultiple() {
if len(val) > 0 {
return val[len(val)-1] // the last selected
}
@@ -345,7 +349,7 @@ func (f *SchemaField) PrepareValue(value any) any {
ids := list.ToUniqueStringSlice(value)
options, _ := f.Options.(*RelationOptions)
if options.MaxSelect != nil && *options.MaxSelect <= 1 {
if !options.IsMultiple() {
if len(ids) > 0 {
return ids[len(ids)-1] // the last selected
}
@@ -399,7 +403,12 @@ func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, m
// -------------------------------------------------------------------
// FieldOptions interfaces that defines common methods that every field options struct has.
// MultiValuer defines common interface methods that every multi-valued (eg. with MaxSelect) field option struct has.
type MultiValuer interface {
IsMultiple() bool
}
// FieldOptions defines common interface methods that every field option struct has.
type FieldOptions interface {
Validate() error
}
@@ -564,6 +573,12 @@ func (o SelectOptions) Validate() error {
)
}
// IsMultiple implements MultiValuer interface and checks whether the
// current field options support multiple values.
func (o SelectOptions) IsMultiple() bool {
return o.MaxSelect > 1
}
// -------------------------------------------------------------------
type JsonOptions struct {
@@ -575,6 +590,8 @@ func (o JsonOptions) Validate() error {
// -------------------------------------------------------------------
var _ MultiValuer = (*FileOptions)(nil)
type FileOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
@@ -593,8 +610,16 @@ func (o FileOptions) Validate() error {
)
}
// IsMultiple implements MultiValuer interface and checks whether the
// current field options support multiple values.
func (o FileOptions) IsMultiple() bool {
return o.MaxSelect > 1
}
// -------------------------------------------------------------------
var _ MultiValuer = (*RelationOptions)(nil)
type RelationOptions struct {
// CollectionId is the id of the related collection.
CollectionId string `form:"collectionId" json:"collectionId"`
@@ -632,6 +657,12 @@ func (o RelationOptions) Validate() error {
)
}
// IsMultiple implements MultiValuer interface and checks whether the
// current field options support multiple values.
func (o RelationOptions) IsMultiple() bool {
return o.MaxSelect == nil || *o.MaxSelect > 1
}
// -------------------------------------------------------------------
// Deprecated: Will be removed in v0.9+
+67
View File
@@ -1983,6 +1983,28 @@ func TestSelectOptionsValidate(t *testing.T) {
checkFieldOptionsScenarios(t, scenarios)
}
func TestSelectOptionsIsMultiple(t *testing.T) {
scenarios := []struct {
maxSelect int
expect bool
}{
{-1, false},
{0, false},
{1, false},
{2, true},
}
for i, s := range scenarios {
opt := schema.SelectOptions{
MaxSelect: s.maxSelect,
}
if v := opt.IsMultiple(); v != s.expect {
t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
}
}
}
func TestJsonOptionsValidate(t *testing.T) {
scenarios := []fieldOptionsScenario{
{
@@ -2053,6 +2075,28 @@ func TestFileOptionsValidate(t *testing.T) {
checkFieldOptionsScenarios(t, scenarios)
}
func TestFileOptionsIsMultiple(t *testing.T) {
scenarios := []struct {
maxSelect int
expect bool
}{
{-1, false},
{0, false},
{1, false},
{2, true},
}
for i, s := range scenarios {
opt := schema.FileOptions{
MaxSelect: s.maxSelect,
}
if v := opt.IsMultiple(); v != s.expect {
t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
}
}
}
func TestRelationOptionsValidate(t *testing.T) {
scenarios := []fieldOptionsScenario{
{
@@ -2088,3 +2132,26 @@ func TestRelationOptionsValidate(t *testing.T) {
checkFieldOptionsScenarios(t, scenarios)
}
func TestRelationOptionsIsMultiple(t *testing.T) {
scenarios := []struct {
maxSelect *int
expect bool
}{
{nil, true},
{types.Pointer(-1), false},
{types.Pointer(0), false},
{types.Pointer(1), false},
{types.Pointer(2), true},
}
for i, s := range scenarios {
opt := schema.RelationOptions{
MaxSelect: s.maxSelect,
}
if v := opt.IsMultiple(); v != s.expect {
t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
}
}
}
+7
View File
@@ -55,6 +55,7 @@ type Settings struct {
OIDCAuth AuthProviderConfig `form:"oidcAuth" json:"oidcAuth"`
OIDC2Auth AuthProviderConfig `form:"oidc2Auth" json:"oidc2Auth"`
OIDC3Auth AuthProviderConfig `form:"oidc3Auth" json:"oidc3Auth"`
AppleAuth AuthProviderConfig `form:"appleAuth" json:"appleAuth"`
}
// New creates and returns a new default Settings instance.
@@ -156,6 +157,9 @@ func New() *Settings {
OIDC3Auth: AuthProviderConfig{
Enabled: false,
},
AppleAuth: AuthProviderConfig{
Enabled: false,
},
}
}
@@ -192,6 +196,7 @@ func (s *Settings) Validate() error {
validation.Field(&s.OIDCAuth),
validation.Field(&s.OIDC2Auth),
validation.Field(&s.OIDC3Auth),
validation.Field(&s.AppleAuth),
)
}
@@ -251,6 +256,7 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.OIDCAuth.ClientSecret,
&clone.OIDC2Auth.ClientSecret,
&clone.OIDC3Auth.ClientSecret,
&clone.AppleAuth.ClientSecret,
}
// mask all sensitive fields
@@ -287,6 +293,7 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
auth.NameOIDC: s.OIDCAuth,
auth.NameOIDC + "2": s.OIDC2Auth,
auth.NameOIDC + "3": s.OIDC3Auth,
auth.NameApple: s.AppleAuth,
}
}
+8
View File
@@ -63,6 +63,8 @@ func TestSettingsValidate(t *testing.T) {
s.OIDC2Auth.ClientId = ""
s.OIDC3Auth.Enabled = true
s.OIDC3Auth.ClientId = ""
s.AppleAuth.Enabled = true
s.AppleAuth.ClientId = ""
// check if Validate() is triggering the members validate methods.
err := s.Validate()
@@ -98,6 +100,7 @@ func TestSettingsValidate(t *testing.T) {
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
}
errBytes, _ := json.Marshal(err)
@@ -160,6 +163,8 @@ func TestSettingsMerge(t *testing.T) {
s2.OIDC2Auth.ClientId = "oidc2_test"
s2.OIDC3Auth.Enabled = true
s2.OIDC3Auth.ClientId = "oidc3_test"
s2.AppleAuth.Enabled = true
s2.AppleAuth.ClientId = "apple_test"
if err := s1.Merge(s2); err != nil {
t.Fatal(err)
@@ -243,6 +248,7 @@ func TestSettingsRedactClone(t *testing.T) {
s1.OIDCAuth.ClientSecret = testSecret
s1.OIDC2Auth.ClientSecret = testSecret
s1.OIDC3Auth.ClientSecret = testSecret
s1.AppleAuth.ClientSecret = testSecret
s1Bytes, err := json.Marshal(s1)
if err != nil {
@@ -297,6 +303,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
s.OIDCAuth.ClientId = "oidc_test"
s.OIDC2Auth.ClientId = "oidc2_test"
s.OIDC3Auth.ClientId = "oidc3_test"
s.AppleAuth.ClientId = "apple_test"
result := s.NamedAuthProviderConfigs()
@@ -324,6 +331,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
`"oidc":{"enabled":false,"clientId":"oidc_test"`,
`"oidc2":{"enabled":false,"clientId":"oidc2_test"`,
`"oidc3":{"enabled":false,"clientId":"oidc3_test"`,
`"apple":{"enabled":false,"clientId":"apple_test"`,
}
for _, p := range expectedParts {
if !strings.Contains(encodedStr, p) {
+1
View File
@@ -123,6 +123,7 @@ func (p *plugin) createCommand() *cobra.Command {
- down [number] - reverts the last [number] applied migrations
- create name - creates new blank migration template file
- collections - creates new migration file with snapshot of the local collections configuration
- history-sync - ensures that the _migrations history table doesn't have references to deleted migration files
`
command := &cobra.Command{
+32
View File
@@ -31,6 +31,9 @@ migrate((db) => {
"type": "auth",
"system": true,
"schema": [],
"indexes": [
"create index test on new_name (id)"
],
"listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0",
"viewRule": "id = \"1\"",
"createRule": null,
@@ -81,6 +84,9 @@ func init() {
"type": "auth",
"system": true,
"schema": [],
"indexes": [
"create index test on new_name (id)"
],
"listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `",
"viewRule": "id = \"1\"",
"createRule": null,
@@ -143,6 +149,7 @@ func init() {
collection.Updated = collection.Created
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.Indexes = types.JsonArray[string]{"create index test on new_name (id)"}
collection.SetOptions(models.CollectionAuthOptions{
ManageRule: types.Pointer("created > 0"),
MinPasswordLength: 20,
@@ -201,6 +208,9 @@ migrate((db) => {
"type": "auth",
"system": false,
"schema": [],
"indexes": [
"create index test on test456 (id)"
],
"listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0",
"viewRule": "id = \"1\"",
"createRule": null,
@@ -255,6 +265,9 @@ func init() {
"type": "auth",
"system": false,
"schema": [],
"indexes": [
"create index test on test456 (id)"
],
"listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `",
"viewRule": "id = \"1\"",
"createRule": null,
@@ -305,6 +318,7 @@ func init() {
collection.Updated = collection.Created
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.Indexes = types.JsonArray[string]{"create index test on test456 (id)"}
collection.SetOptions(models.CollectionAuthOptions{
ManageRule: types.Pointer("created > 0"),
MinPasswordLength: 20,
@@ -367,6 +381,9 @@ migrate((db) => {
collection.listRule = null
collection.deleteRule = "updated > 0 && @request.auth.id != ''"
collection.options = {}
collection.indexes = [
"create index test1 on test456_update (f1_name)"
]
// remove
collection.schema.removeField("f3_id")
@@ -419,6 +436,9 @@ migrate((db) => {
"onlyEmailDomains": null,
"requireEmail": false
}
collection.indexes = [
"create index test1 on test456 (f1_name)"
]
// add
collection.schema.addField(new SchemaField({
@@ -488,6 +508,10 @@ func init() {
json.Unmarshal([]byte(` + "`" + `{}` + "`" + `), &options)
collection.SetOptions(options)
json.Unmarshal([]byte(` + "`" + `[
"create index test1 on test456_update (f1_name)"
]` + "`" + `), &collection.Indexes)
// remove
collection.Schema.RemoveField("f3_id")
@@ -554,6 +578,10 @@ func init() {
}` + "`" + `), &options)
collection.SetOptions(options)
json.Unmarshal([]byte(` + "`" + `[
"create index test1 on test456 (f1_name)"
]` + "`" + `), &collection.Indexes)
// add
del_f3_name := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
@@ -614,6 +642,7 @@ func init() {
collection.Updated = collection.Created
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.Indexes = types.JsonArray[string]{"create index test1 on test456 (f1_name)"}
collection.SetOptions(models.CollectionAuthOptions{
ManageRule: types.Pointer("created > 0"),
MinPasswordLength: 20,
@@ -652,6 +681,9 @@ func init() {
collection.Type = models.CollectionTypeBase
collection.DeleteRule = types.Pointer(`updated > 0 && @request.auth.id != ''`)
collection.ListRule = nil
collection.Indexes = types.JsonArray[string]{
"create index test1 on test456_update (f1_name)",
}
collection.NormalizeOptions()
collection.Schema.RemoveField("f3_id")
collection.Schema.AddField(&schema.SchemaField{
+29
View File
@@ -196,6 +196,20 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection)
downParts = append(downParts, fmt.Sprintf("%s.options = %s", varName, rawOldOptions))
}
// Indexes
rawNewIndexes, err := marhshalWithoutEscape(new.Indexes, " ", " ")
if err != nil {
return "", err
}
rawOldIndexes, err := marhshalWithoutEscape(old.Indexes, " ", " ")
if err != nil {
return "", err
}
if !bytes.Equal(rawNewIndexes, rawOldIndexes) {
upParts = append(upParts, fmt.Sprintf("%s.indexes = %s", varName, rawNewIndexes))
downParts = append(downParts, fmt.Sprintf("%s.indexes = %s", varName, rawOldIndexes))
}
// ensure new line between regular and collection fields
if len(upParts) > 0 {
upParts[len(upParts)-1] += "\n"
@@ -571,6 +585,21 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection)
downParts = append(downParts, fmt.Sprintf("%s.SetOptions(options)\n", varName))
}
// Indexes
rawNewIndexes, err := marhshalWithoutEscape(new.Indexes, "\t\t", "\t")
if err != nil {
return "", err
}
rawOldIndexes, err := marhshalWithoutEscape(old.Indexes, "\t\t", "\t")
if err != nil {
return "", err
}
if !bytes.Equal(rawNewIndexes, rawOldIndexes) {
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawNewIndexes)), varName))
// ---
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawOldIndexes)), varName))
}
// Schema
// ---------------------------------------------------------------
// deleted fields
+46 -25
View File
@@ -512,44 +512,65 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
}
cleanFieldName := inflector.Columnify(field.Name)
newCollectionName := relCollection.Name
prefixedFieldName := r.activeTableAlias + "." + cleanFieldName
newTableAlias := r.activeTableAlias + "_" + cleanFieldName
newCollectionName := relCollection.Name
if !options.IsMultiple() {
r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias, prefixedFieldName)),
)
} else {
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
r.resolver.registerJoin(jsonEach(prefixedFieldName), jeAlias, nil)
r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
)
}
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
jePair := r.activeTableAlias + "." + cleanFieldName
r.resolver.registerJoin(jsonEach(jePair), jeAlias, nil)
r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
)
r.activeCollectionName = newCollectionName
r.activeTableAlias = newTableAlias
// ---
// join the relation to the multi-match subquery
// ---
if options.MaxSelect == nil || *options.MaxSelect != 1 {
if options.IsMultiple() {
r.withMultiMatch = true
}
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
r.multiMatchActiveTableAlias = newTableAlias2
prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: jsonEach(jePair2),
tableAlias: jeAlias2,
},
&join{
tableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
},
)
if !options.IsMultiple() {
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)),
},
)
} else {
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: jsonEach(prefixedFieldName2),
tableAlias: jeAlias2,
},
&join{
tableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
},
)
}
r.multiMatchActiveTableAlias = newTableAlias2
// ---
}
+5 -4
View File
@@ -84,6 +84,7 @@ func NewRecordFieldResolver(
`^\@request\.auth\.[\w\.\:]*\w+$`,
`^\@request\.data\.[\w\.\:]*\w+$`,
`^\@request\.query\.[\w\.\:]*\w+$`,
`^\@request\.headers\.\w+$`,
`^\@collection\.\w+\.[\w\.\:]*\w+$`,
},
}
@@ -92,6 +93,7 @@ func NewRecordFieldResolver(
if r.requestData != nil {
r.staticRequestData["method"] = r.requestData.Method
r.staticRequestData["query"] = r.requestData.Query
r.staticRequestData["headers"] = r.requestData.Headers
r.staticRequestData["data"] = r.requestData.Data
r.staticRequestData["auth"] = nil
if r.requestData.AuthRecord != nil {
@@ -132,6 +134,7 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
// project.screen.status
// @request.status
// @request.query.filter
// @request.headers.x_token
// @request.auth.someRelation.name
// @request.data.someRelation.name
// @request.data.someField
@@ -249,10 +252,8 @@ func extractNestedMapVal(m map[string]any, keys ...string) (any, error) {
return nil, fmt.Errorf("at least one key should be provided")
}
var result any
var ok bool
if result, ok = m[keys[0]]; !ok {
result, ok := m[keys[0]]
if !ok {
return nil, fmt.Errorf("invalid key path - missing key %q", keys[0])
}
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+222
View File
@@ -0,0 +1,222 @@
package auth
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/spf13/cast"
"golang.org/x/oauth2"
)
var _ Provider = (*Apple)(nil)
// NameApple is the unique name of the Apple provider.
const NameApple string = "apple"
// Apple allows authentication via Apple OAuth2.
//
// [OIDC differences]: https://bitbucket.org/openid/connect/src/master/How-Sign-in-with-Apple-differs-from-OpenID-Connect.md
type Apple struct {
*baseProvider
jwksUrl string
}
// NewAppleProvider creates a new Apple provider instance with some defaults.
func NewAppleProvider() *Apple {
return &Apple{
baseProvider: &baseProvider{
scopes: nil, // custom scopes are currently not supported since they require a POST redirect
ctx: context.Background(),
authUrl: "https://appleid.apple.com/auth/authorize",
tokenUrl: "https://appleid.apple.com/auth/token",
},
jwksUrl: "https://appleid.apple.com/auth/keys",
}
}
// FetchAuthUser returns an AuthUser instance based on the provided token.
//
// API reference: https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse.
func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"sub"`
Email string `json:"email"`
EmailVerified any `json:"email_verified"` // could be string or bool
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: extracted.Id,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
if cast.ToBool(extracted.EmailVerified) {
user.Email = extracted.Email
}
return user, nil
}
// FetchRawUserData implements Provider.FetchRawUserData interface.
//
// Apple doesn't have a UserInfo endpoint and claims about users
// are instead included in the "id_token" (https://openid.net/specs/openid-connect-core-1_0.html#id_tokenExample)
func (p *Apple) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
idToken, _ := token.Extra("id_token").(string)
claims, err := p.parseAndVerifyIdToken(idToken)
if err != nil {
return nil, err
}
return json.Marshal(claims)
}
// -------------------------------------------------------------------
func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
if idToken == "" {
return nil, errors.New("empty id_token")
}
// extract the token header params and claims
// ---
claims := jwt.MapClaims{}
t, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
if err != nil {
return nil, err
}
// validate common claims per https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user#3383769
// ---
if !claims.VerifyIssuer("https://appleid.apple.com", true) {
return nil, errors.New("iss must be https://appleid.apple.com")
}
if !claims.VerifyAudience(p.clientId, true) {
return nil, errors.New("aud must be the developer's client_id")
}
// fetch the public key set
// ---
kid, _ := t.Header["kid"].(string)
if kid == "" {
return nil, errors.New("missing kid header value")
}
key, err := p.fetchJWK(kid)
if err != nil {
return nil, err
}
// decode the key params per RFC 7518 (https://tools.ietf.org/html/rfc7518#section-6.3)
// and construct a valid publicKey from them
// ---
exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.E, "="))
if err != nil {
return nil, err
}
modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.N, "="))
if err != nil {
return nil, err
}
publicKey := &rsa.PublicKey{
// https://tools.ietf.org/html/rfc7517#appendix-A.1
E: int(big.NewInt(0).SetBytes(exponent).Uint64()),
N: big.NewInt(0).SetBytes(modulus),
}
// verify the id_token
// ---
parser := jwt.NewParser(jwt.WithValidMethods([]string{key.Alg}))
parsedToken, err := parser.Parse(idToken, func(t *jwt.Token) (any, error) {
return publicKey, nil
})
if err != nil {
return nil, err
}
if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid {
return claims, nil
}
return nil, errors.New("the parsed id_token is invalid")
}
type jwk struct {
Kty string
Kid string
Use string
Alg string
N string
E string
}
func (p *Apple) fetchJWK(kid string) (*jwk, error) {
req, err := http.NewRequestWithContext(p.ctx, "GET", p.jwksUrl, nil)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
rawBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// http.Client.Get doesn't treat non 2xx responses as error
if res.StatusCode >= 400 {
return nil, fmt.Errorf(
"failed to verify the provided id_token (%d):\n%s",
res.StatusCode,
string(rawBody),
)
}
jwks := struct {
Keys []*jwk
}{}
if err := json.Unmarshal(rawBody, &jwks); err != nil {
return nil, err
}
for _, key := range jwks.Keys {
if key.Kid == kid {
return key, nil
}
}
return nil, fmt.Errorf("jwk with kid %q was not found", kid)
}
+9
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"errors"
"net/http"
@@ -21,6 +22,12 @@ type AuthUser struct {
// Provider defines a common interface for an OAuth2 client.
type Provider interface {
// Scopes returns the context associated with the provider (if any).
Context() context.Context
// SetContext assigns the specified context to the current provider.
SetContext(ctx context.Context)
// Scopes returns the provider access permissions that will be requested.
Scopes() []string
@@ -120,6 +127,8 @@ func NewProviderByName(name string) (Provider, error) {
return NewOIDCProvider(), nil
case NameOIDC + "3":
return NewOIDCProvider(), nil
case NameApple:
return NewAppleProvider(), nil
default:
return nil, errors.New("Missing provider " + name)
}
+9
View File
@@ -171,4 +171,13 @@ func TestNewProviderByName(t *testing.T) {
if _, ok := p.(*auth.OIDC); !ok {
t.Error("Expected to be instance of *auth.OIDC")
}
// apple
p, err = auth.NewProviderByName(auth.NameApple)
if err != nil {
t.Errorf("Expected nil, got error %v", err)
}
if _, ok := p.(*auth.Apple); !ok {
t.Error("Expected to be instance of *auth.Apple")
}
}
+38 -27
View File
@@ -11,6 +11,7 @@ import (
// baseProvider defines common fields and methods used by OAuth2 client providers.
type baseProvider struct {
ctx context.Context
scopes []string
clientId string
clientSecret string
@@ -20,94 +21,104 @@ type baseProvider struct {
userApiUrl string
}
// Scopes implements Provider.Scopes interface.
// Context implements Provider.Context() interface method.
func (p *baseProvider) Context() context.Context {
return p.ctx
}
// SetContext implements Provider.SetContext() interface method.
func (p *baseProvider) SetContext(ctx context.Context) {
p.ctx = ctx
}
// Scopes implements Provider.Scopes() interface method.
func (p *baseProvider) Scopes() []string {
return p.scopes
}
// SetScopes implements Provider.SetScopes interface.
// SetScopes implements Provider.SetScopes() interface method.
func (p *baseProvider) SetScopes(scopes []string) {
p.scopes = scopes
}
// ClientId implements Provider.ClientId interface.
// ClientId implements Provider.ClientId() interface method.
func (p *baseProvider) ClientId() string {
return p.clientId
}
// SetClientId implements Provider.SetClientId interface.
// SetClientId implements Provider.SetClientId() interface method.
func (p *baseProvider) SetClientId(clientId string) {
p.clientId = clientId
}
// ClientSecret implements Provider.ClientSecret interface.
// ClientSecret implements Provider.ClientSecret() interface method.
func (p *baseProvider) ClientSecret() string {
return p.clientSecret
}
// SetClientSecret implements Provider.SetClientSecret interface.
// SetClientSecret implements Provider.SetClientSecret() interface method.
func (p *baseProvider) SetClientSecret(secret string) {
p.clientSecret = secret
}
// RedirectUrl implements Provider.RedirectUrl interface.
// RedirectUrl implements Provider.RedirectUrl() interface method.
func (p *baseProvider) RedirectUrl() string {
return p.redirectUrl
}
// SetRedirectUrl implements Provider.SetRedirectUrl interface.
// SetRedirectUrl implements Provider.SetRedirectUrl() interface method.
func (p *baseProvider) SetRedirectUrl(url string) {
p.redirectUrl = url
}
// AuthUrl implements Provider.AuthUrl interface.
// AuthUrl implements Provider.AuthUrl() interface method.
func (p *baseProvider) AuthUrl() string {
return p.authUrl
}
// SetAuthUrl implements Provider.SetAuthUrl interface.
// SetAuthUrl implements Provider.SetAuthUrl() interface method.
func (p *baseProvider) SetAuthUrl(url string) {
p.authUrl = url
}
// TokenUrl implements Provider.TokenUrl interface.
// TokenUrl implements Provider.TokenUrl() interface method.
func (p *baseProvider) TokenUrl() string {
return p.tokenUrl
}
// SetTokenUrl implements Provider.SetTokenUrl interface.
// SetTokenUrl implements Provider.SetTokenUrl() interface method.
func (p *baseProvider) SetTokenUrl(url string) {
p.tokenUrl = url
}
// UserApiUrl implements Provider.UserApiUrl interface.
// UserApiUrl implements Provider.UserApiUrl() interface method.
func (p *baseProvider) UserApiUrl() string {
return p.userApiUrl
}
// SetUserApiUrl implements Provider.SetUserApiUrl interface.
// SetUserApiUrl implements Provider.SetUserApiUrl() interface method.
func (p *baseProvider) SetUserApiUrl(url string) {
p.userApiUrl = url
}
// BuildAuthUrl implements Provider.BuildAuthUrl interface.
// BuildAuthUrl implements Provider.BuildAuthUrl() interface method.
func (p *baseProvider) BuildAuthUrl(state string, opts ...oauth2.AuthCodeOption) string {
return p.oauth2Config().AuthCodeURL(state, opts...)
}
// FetchToken implements Provider.FetchToken interface.
// FetchToken implements Provider.FetchToken() interface method.
func (p *baseProvider) FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return p.oauth2Config().Exchange(context.Background(), code, opts...)
return p.oauth2Config().Exchange(p.ctx, code, opts...)
}
// Client implements Provider.Client interface.
// Client implements Provider.Client() interface method.
func (p *baseProvider) Client(token *oauth2.Token) *http.Client {
return p.oauth2Config().Client(context.Background(), token)
return p.oauth2Config().Client(p.ctx, token)
}
// FetchRawUserData implements Provider.FetchRawUserData interface.
// FetchRawUserData implements Provider.FetchRawUserData() interface method.
func (p *baseProvider) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequest("GET", p.userApiUrl, nil)
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userApiUrl, nil)
if err != nil {
return nil, err
}
@@ -119,23 +130,23 @@ func (p *baseProvider) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.Token) ([]byte, error) {
client := p.Client(token)
response, err := client.Do(req)
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
defer res.Body.Close()
result, err := io.ReadAll(response.Body)
result, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// http.Client.Get doesn't treat non 2xx responses as error
if response.StatusCode >= 400 {
if res.StatusCode >= 400 {
return nil, fmt.Errorf(
"Failed to fetch OAuth2 user profile via %s (%d):\n%s",
"failed to fetch OAuth2 user profile via %s (%d):\n%s",
p.userApiUrl,
response.StatusCode,
res.StatusCode,
string(result),
)
}
+17
View File
@@ -1,11 +1,28 @@
package auth
import (
"context"
"testing"
"golang.org/x/oauth2"
)
func TestContext(t *testing.T) {
b := baseProvider{}
before := b.Scopes()
if before != nil {
t.Errorf("Expected nil context, got %v", before)
}
b.SetContext(context.Background())
after := b.Scopes()
if after != nil {
t.Error("Expected non-nil context")
}
}
func TestScopes(t *testing.T) {
b := baseProvider{}
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"fmt"
@@ -22,6 +23,7 @@ func NewDiscordProvider() *Discord {
// https://discord.com/developers/docs/topics/oauth2
// https://discord.com/developers/docs/resources/user#get-current-user
return &Discord{&baseProvider{
ctx: context.Background(),
scopes: []string{"identify", "email"},
authUrl: "https://discord.com/api/oauth2/authorize",
tokenUrl: "https://discord.com/api/oauth2/token",
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
@@ -20,6 +21,7 @@ type Facebook struct {
// NewFacebookProvider creates new Facebook provider instance with some defaults.
func NewFacebookProvider() *Facebook {
return &Facebook{&baseProvider{
ctx: context.Background(),
scopes: []string{"email"},
authUrl: facebook.Endpoint.AuthURL,
tokenUrl: facebook.Endpoint.TokenURL,
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"strconv"
@@ -20,6 +21,7 @@ type Gitea struct {
// NewGiteaProvider creates new Gitea provider instance with some defaults.
func NewGiteaProvider() *Gitea {
return &Gitea{&baseProvider{
ctx: context.Background(),
scopes: []string{"read:user", "user:email"},
authUrl: "https://gitea.com/login/oauth/authorize",
tokenUrl: "https://gitea.com/login/oauth/access_token",
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"io"
"strconv"
@@ -22,6 +23,7 @@ type Gitee struct {
// NewGiteeProvider creates new Gitee provider instance with some defaults.
func NewGiteeProvider() *Gitee {
return &Gitee{&baseProvider{
ctx: context.Background(),
scopes: []string{"user_info", "emails"},
authUrl: "https://gitee.com/oauth/authorize",
tokenUrl: "https://gitee.com/oauth/token",
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"io"
"strconv"
@@ -22,6 +23,7 @@ type Github struct {
// NewGithubProvider creates new Github provider instance with some defaults.
func NewGithubProvider() *Github {
return &Github{&baseProvider{
ctx: context.Background(),
scopes: []string{"read:user", "user:email"},
authUrl: github.Endpoint.AuthURL,
tokenUrl: github.Endpoint.TokenURL,
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"strconv"
@@ -20,6 +21,7 @@ type Gitlab struct {
// NewGitlabProvider creates new Gitlab provider instance with some defaults.
func NewGitlabProvider() *Gitlab {
return &Gitlab{&baseProvider{
ctx: context.Background(),
scopes: []string{"read_user"},
authUrl: "https://gitlab.com/oauth/authorize",
tokenUrl: "https://gitlab.com/oauth/token",
+11 -5
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
@@ -19,6 +20,7 @@ type Google struct {
// NewGoogleProvider creates new Google provider instance with some defaults.
func NewGoogleProvider() *Google {
return &Google{&baseProvider{
ctx: context.Background(),
scopes: []string{
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
@@ -42,10 +44,11 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Id string
Name string
Email string
Picture string
Id string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Picture string `json:"picture"`
VerifiedEmail bool `json:"verified_email"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
@@ -54,12 +57,15 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user := &AuthUser{
Id: extracted.Id,
Name: extracted.Name,
Email: extracted.Email,
AvatarUrl: extracted.Picture,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
if extracted.VerifiedEmail {
user.Email = extracted.Email
}
return user, nil
}
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"strconv"
@@ -21,6 +22,7 @@ type Kakao struct {
// NewKakaoProvider creates a new Kakao provider instance with some defaults.
func NewKakaoProvider() *Kakao {
return &Kakao{&baseProvider{
ctx: context.Background(),
scopes: []string{"account_email", "profile_nickname", "profile_image"},
authUrl: kakao.Endpoint.AuthURL,
tokenUrl: kakao.Endpoint.TokenURL,
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
@@ -19,6 +20,7 @@ type Livechat struct {
// NewLivechatProvider creates new Livechat provider instance with some defaults.
func NewLivechatProvider() *Livechat {
return &Livechat{&baseProvider{
ctx: context.Background(),
scopes: []string{}, // default scopes are specified from the provider dashboard
authUrl: "https://accounts.livechat.com/",
tokenUrl: "https://accounts.livechat.com/token",
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
@@ -21,6 +22,7 @@ type Microsoft struct {
func NewMicrosoftProvider() *Microsoft {
endpoints := microsoft.AzureADEndpoint("")
return &Microsoft{&baseProvider{
ctx: context.Background(),
scopes: []string{"User.Read"},
authUrl: endpoints.AuthURL,
tokenUrl: endpoints.TokenURL,
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
@@ -19,6 +20,7 @@ type OIDC struct {
// NewOIDCProvider creates new OpenID Connect (OIDC) provider instance with some defaults.
func NewOIDCProvider() *OIDC {
return &OIDC{&baseProvider{
ctx: context.Background(),
scopes: []string{
"openid", // minimal requirement to return the id
"email",
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
@@ -20,6 +21,7 @@ type Spotify struct {
// NewSpotifyProvider creates a new Spotify provider instance with some defaults.
func NewSpotifyProvider() *Spotify {
return &Spotify{&baseProvider{
ctx: context.Background(),
scopes: []string{
"user-read-private",
// currently Spotify doesn't return information whether the email is verified or not
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"strconv"
@@ -20,6 +21,7 @@ type Strava struct {
// NewStravaProvider creates new Strava provider instance with some defaults.
func NewStravaProvider() *Strava {
return &Strava{&baseProvider{
ctx: context.Background(),
scopes: []string{
"profile:read_all",
},
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -22,6 +23,7 @@ type Twitch struct {
// NewTwitchProvider creates new Twitch provider instance with some defaults.
func NewTwitchProvider() *Twitch {
return &Twitch{&baseProvider{
ctx: context.Background(),
scopes: []string{"user:read:email"},
authUrl: twitch.Endpoint.AuthURL,
tokenUrl: twitch.Endpoint.TokenURL,
+2
View File
@@ -1,6 +1,7 @@
package auth
import (
"context"
"encoding/json"
"golang.org/x/oauth2"
@@ -19,6 +20,7 @@ type Twitter struct {
// NewTwitterProvider creates new Twitter provider instance with some defaults.
func NewTwitterProvider() *Twitter {
return &Twitter{&baseProvider{
ctx: context.Background(),
scopes: []string{
"users.read",
+194
View File
@@ -0,0 +1,194 @@
package dbutils
import (
"regexp"
"strings"
"github.com/pocketbase/pocketbase/tools/tokenizer"
)
var (
indexRegex = regexp.MustCompile(`(?im)create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s+\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?`)
indexColumnRegex = regexp.MustCompile(`(?im)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$`)
)
// IndexColumn represents a single parsed SQL index column.
type IndexColumn struct {
Name string `json:"name"` // identifier or expression
Collate string `json:"collate"`
Sort string `json:"sort"`
}
// Index represents a single parsed SQL CREATE INDEX expression.
type Index struct {
Unique bool `json:"unique"`
Optional bool `json:"optional"`
SchemaName string `json:"schemaName"`
IndexName string `json:"indexName"`
TableName string `json:"tableName"`
Columns []IndexColumn `json:"columns"`
Where string `json:"where"`
}
// IsValid checks if the current Index contains the minimum required fields to be considered valid.
func (idx Index) IsValid() bool {
return idx.IndexName != "" && idx.TableName != "" && len(idx.Columns) > 0
}
// Build returns a "CREATE INDEX" SQL string from the current index parts.
//
// Returns empty string if idx.IsValid() is false.
func (idx Index) Build() string {
if !idx.IsValid() {
return ""
}
var str strings.Builder
str.WriteString("CREATE ")
if idx.Unique {
str.WriteString("UNIQUE ")
}
str.WriteString("INDEX ")
if idx.Optional {
str.WriteString("IF NOT EXISTS ")
}
if idx.SchemaName != "" {
str.WriteString("`")
str.WriteString(idx.SchemaName)
str.WriteString("`.")
}
str.WriteString("`")
str.WriteString(idx.IndexName)
str.WriteString("` ")
str.WriteString("ON `")
str.WriteString(idx.TableName)
str.WriteString("` (")
if len(idx.Columns) > 1 {
str.WriteString("\n ")
}
var hasCol bool
for _, col := range idx.Columns {
trimmedColName := strings.TrimSpace(col.Name)
if trimmedColName == "" {
continue
}
if hasCol {
str.WriteString(",\n ")
}
if strings.Contains(col.Name, "(") || strings.Contains(col.Name, " ") {
// most likely an expression
str.WriteString(trimmedColName)
} else {
// regular identifier
str.WriteString("`")
str.WriteString(trimmedColName)
str.WriteString("`")
}
if col.Collate != "" {
str.WriteString(" COLLATE ")
str.WriteString(col.Collate)
}
if col.Sort != "" {
str.WriteString(" ")
str.WriteString(strings.ToUpper(col.Sort))
}
hasCol = true
}
if hasCol && len(idx.Columns) > 1 {
str.WriteString("\n")
}
str.WriteString(")")
if idx.Where != "" {
str.WriteString(" WHERE ")
str.WriteString(idx.Where)
}
return str.String()
}
// ParseIndex parses the provided "CREATE INDEX" SQL string into Index struct.
func ParseIndex(createIndexExpr string) Index {
result := Index{}
matches := indexRegex.FindStringSubmatch(createIndexExpr)
if len(matches) != 7 {
return result
}
trimChars := "`\"'[]\r\n\t\f\v "
// Unique
// ---
result.Unique = strings.TrimSpace(matches[1]) != ""
// Optional (aka. "IF NOT EXISTS")
// ---
result.Optional = strings.TrimSpace(matches[2]) != ""
// SchemaName and IndexName
// ---
nameTk := tokenizer.NewFromString(matches[3])
nameTk.Separators('.')
nameParts, _ := nameTk.ScanAll()
if len(nameParts) == 2 {
result.SchemaName = strings.Trim(nameParts[0], trimChars)
result.IndexName = strings.Trim(nameParts[1], trimChars)
} else {
result.IndexName = strings.Trim(nameParts[0], trimChars)
}
// TableName
// ---
result.TableName = strings.Trim(matches[4], trimChars)
// Columns
// ---
columnsTk := tokenizer.NewFromString(matches[5])
columnsTk.Separators(',')
rawColumns, _ := columnsTk.ScanAll()
result.Columns = make([]IndexColumn, 0, len(rawColumns))
for _, col := range rawColumns {
colMatches := indexColumnRegex.FindStringSubmatch(col)
if len(colMatches) != 4 {
continue
}
trimmedName := strings.Trim(colMatches[1], trimChars)
if trimmedName == "" {
continue
}
result.Columns = append(result.Columns, IndexColumn{
Name: trimmedName,
Collate: strings.TrimSpace(colMatches[2]),
Sort: strings.ToUpper(colMatches[3]),
})
}
// WHERE expression
// ---
result.Where = strings.TrimSpace(matches[6])
return result
}
+216
View File
@@ -0,0 +1,216 @@
package dbutils_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/pocketbase/pocketbase/tools/dbutils"
)
func TestParseIndex(t *testing.T) {
scenarios := []struct {
index string
expected dbutils.Index
}{
// invalid
{
`invalid`,
dbutils.Index{},
},
// simple
{
`create index indexname on tablename (col1)`,
dbutils.Index{
IndexName: "indexname",
TableName: "tablename",
Columns: []dbutils.IndexColumn{
{Name: "col1"},
},
},
},
// all fields
{
`CREATE UNIQUE INDEX IF NOT EXISTS "schemaname".[indexname] on 'tablename' (
col0,
` + "`" + `col1` + "`" + `,
json_extract("col2", "$.a") asc,
"col3" collate NOCASE,
"col4" collate RTRIM desc
) where test = 1`,
dbutils.Index{
Unique: true,
Optional: true,
SchemaName: "schemaname",
IndexName: "indexname",
TableName: "tablename",
Columns: []dbutils.IndexColumn{
{Name: "col0"},
{Name: "col1"},
{Name: `json_extract("col2", "$.a")`, Sort: "ASC"},
{Name: `col3`, Collate: "NOCASE"},
{Name: `col4`, Collate: "RTRIM", Sort: "DESC"},
},
Where: "test = 1",
},
},
}
for i, s := range scenarios {
result := dbutils.ParseIndex(s.index)
resultRaw, err := json.Marshal(result)
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
expectedRaw, err := json.Marshal(s.expected)
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
if !bytes.Equal(resultRaw, expectedRaw) {
t.Errorf("[%d] Expected \n%s \ngot \n%s", i, expectedRaw, resultRaw)
}
}
}
func TestIndexIsValid(t *testing.T) {
scenarios := []struct {
name string
index dbutils.Index
expected bool
}{
{
"empty",
dbutils.Index{},
false,
},
{
"no index name",
dbutils.Index{
TableName: "table",
Columns: []dbutils.IndexColumn{{Name: "col"}},
},
false,
},
{
"no table name",
dbutils.Index{
IndexName: "index",
Columns: []dbutils.IndexColumn{{Name: "col"}},
},
false,
},
{
"no columns",
dbutils.Index{
IndexName: "index",
TableName: "table",
},
false,
},
{
"min valid",
dbutils.Index{
IndexName: "index",
TableName: "table",
Columns: []dbutils.IndexColumn{{Name: "col"}},
},
true,
},
{
"all fields",
dbutils.Index{
Optional: true,
Unique: true,
SchemaName: "schema",
IndexName: "index",
TableName: "table",
Columns: []dbutils.IndexColumn{{Name: "col"}},
Where: "test = 1 OR test = 2",
},
true,
},
}
for _, s := range scenarios {
result := s.index.IsValid()
if result != s.expected {
t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
}
}
}
func TestIndexBuild(t *testing.T) {
scenarios := []struct {
name string
index dbutils.Index
expected string
}{
{
"empty",
dbutils.Index{},
"",
},
{
"no index name",
dbutils.Index{
TableName: "table",
Columns: []dbutils.IndexColumn{{Name: "col"}},
},
"",
},
{
"no table name",
dbutils.Index{
IndexName: "index",
Columns: []dbutils.IndexColumn{{Name: "col"}},
},
"",
},
{
"no columns",
dbutils.Index{
IndexName: "index",
TableName: "table",
},
"",
},
{
"min valid",
dbutils.Index{
IndexName: "index",
TableName: "table",
Columns: []dbutils.IndexColumn{{Name: "col"}},
},
"CREATE INDEX `index` ON `table` (`col`)",
},
{
"all fields",
dbutils.Index{
Optional: true,
Unique: true,
SchemaName: "schema",
IndexName: "index",
TableName: "table",
Columns: []dbutils.IndexColumn{
{Name: "col1", Collate: "NOCASE", Sort: "asc"},
{Name: "col2", Sort: "desc"},
{Name: `json_extract("col3", "$.a")`, Collate: "NOCASE"},
},
Where: "test = 1 OR test = 2",
},
"CREATE UNIQUE INDEX IF NOT EXISTS `schema`.`index` ON `table` (\n `col1` COLLATE NOCASE ASC,\n `col2` DESC,\n " + `json_extract("col3", "$.a")` + " COLLATE NOCASE\n) WHERE test = 1 OR test = 2",
},
}
for _, s := range scenarios {
result := s.index.Build()
if result != s.expected {
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, result)
}
}
}
+29 -12
View File
@@ -14,9 +14,10 @@ import (
"strconv"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
"github.com/pocketbase/pocketbase/tools/list"
@@ -43,19 +44,31 @@ func NewS3(
) (*System, error) {
ctx := context.Background() // default context
cred := credentials.NewStaticCredentials(accessKey, secretKey, "")
cred := credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Endpoint: aws.String(endpoint),
Credentials: cred,
S3ForcePathStyle: aws.Bool(s3ForcePathStyle),
})
cfg, err := config.LoadDefaultConfig(ctx,
config.WithCredentialsProvider(cred),
config.WithRegion(region),
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
// ensure that the endpoint has url scheme for
// backward compatibility with v1 of the aws sdk
prefixedEndpoint := endpoint
if !strings.Contains(endpoint, "://") {
prefixedEndpoint = "https://" + endpoint
}
return aws.Endpoint{URL: prefixedEndpoint, SigningRegion: region}, nil
})),
)
if err != nil {
return nil, err
}
bucket, err := s3blob.OpenBucket(ctx, sess, bucketName, nil)
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = s3ForcePathStyle
})
bucket, err := s3blob.OpenBucketV2(ctx, client, bucketName, nil)
if err != nil {
return nil, err
}
@@ -398,8 +411,12 @@ func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) err
}
}
opts := &blob.WriterOptions{
ContentType: r.ContentType(),
}
// open a thumb storage writer (aka. prepare for upload)
w, writerErr := s.bucket.NewWriter(s.ctx, thumbKey, nil)
w, writerErr := s.bucket.NewWriter(s.ctx, thumbKey, opts)
if writerErr != nil {
return writerErr
}
+1 -1
View File
@@ -254,7 +254,7 @@ func TestToUniqueStringSlice(t *testing.T) {
{[]any{0, 1, "test", ""}, []string{"0", "1", "test"}},
{[]string{"test1", "test2", "test1"}, []string{"test1", "test2"}},
{`["test1", "test2", "test2"]`, []string{"test1", "test2"}},
{types.JsonArray{"test1", "test2", "test1"}, []string{"test1", "test2"}},
{types.JsonArray[string]{"test1", "test2", "test1"}, []string{"test1", "test2"}},
}
for i, scenario := range scenarios {
+23
View File
@@ -107,6 +107,14 @@ func (r *Runner) Run(args ...string) error {
}
}
return nil
case "history-sync":
if err := r.removeMissingAppliedMigrations(); err != nil {
color.Red(err.Error())
return err
}
color.Green("The %s table was synced with the available migrations.", r.tableName)
return nil
default:
return fmt.Errorf("Unsupported command: %q\n", cmd)
@@ -252,3 +260,18 @@ func (r *Runner) lastAppliedMigrations(limit int) ([]string, error) {
return files, nil
}
func (r *Runner) removeMissingAppliedMigrations() error {
loadedMigrations := r.migrationsList.Items()
names := make([]any, len(loadedMigrations))
for i, migration := range loadedMigrations {
names[i] = migration.File
}
_, err := r.db.Delete(r.tableName, dbx.Not(dbx.HashExp{
"file": names,
})).Execute()
return err
}
+50
View File
@@ -138,6 +138,56 @@ func TestRunnerUpAndDown(t *testing.T) {
}
}
func TestHistorySync(t *testing.T) {
testDB, err := createTestDB()
if err != nil {
t.Fatal(err)
}
defer testDB.Close()
// mock migrations history
l := MigrationsList{}
l.Register(func(db dbx.Builder) error {
return nil
}, func(db dbx.Builder) error {
return nil
}, "1_test")
l.Register(func(db dbx.Builder) error {
return nil
}, func(db dbx.Builder) error {
return nil
}, "2_test")
l.Register(func(db dbx.Builder) error {
return nil
}, func(db dbx.Builder) error {
return nil
}, "3_test")
r, err := NewRunner(testDB.DB, l)
if err != nil {
t.Fatalf("Failed to initialize the runner: %v", err)
}
if _, err := r.Up(); err != nil {
t.Fatalf("Failed to apply the mock migrations: %v", err)
}
if !r.isMigrationApplied(testDB.DB, "2_test") {
t.Fatalf("Expected 2_test migration to be applied")
}
// mock deleted migrations
r.migrationsList.list = []*Migration{r.migrationsList.list[0], r.migrationsList.list[2]}
if err := r.removeMissingAppliedMigrations(); err != nil {
t.Fatalf("Failed to remove missing applied migrations: %v", err)
}
if r.isMigrationApplied(testDB.DB, "2_test") {
t.Fatalf("Expected 2_test migration to NOT be applied")
}
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
+106 -2
View File
@@ -107,9 +107,9 @@ func buildExpr(
switch op {
case fexpr.SignEq, fexpr.SignAnyEq:
expr = dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') = COALESCE(%s, '')", left.Identifier, right.Identifier), mergeParams(left.Params, right.Params))
expr = resolveEqualExpr(true, left, right)
case fexpr.SignNeq, fexpr.SignAnyNeq:
expr = dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') != COALESCE(%s, '')", left.Identifier, right.Identifier), mergeParams(left.Params, right.Params))
expr = resolveEqualExpr(false, left, right)
case fexpr.SignLike, fexpr.SignAnyLike:
// the right side is a column and therefor wrap it with "%" for contains like behavior
if len(right.Params) == 0 {
@@ -232,6 +232,110 @@ func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResu
return nil, errors.New("unresolvable token type")
}
// Resolves = and != expressions in an attempt to minimize the COALESCE
// usage and to gracefully handle null vs empty string normalizations.
//
// The expression `a = "" OR a is null` tends to perform better than
// `COALESCE(a, "") = ""` since the direct match can be accomplished
// with a seek while the COALESCE will induce a table scan.
func resolveEqualExpr(equal bool, left, right *ResolverResult) dbx.Expression {
isLeftEmpty := isEmptyIdentifier(left) || (len(left.Params) == 1 && hasEmptyParamValue(left))
isRightEmpty := isEmptyIdentifier(right) || (len(right.Params) == 1 && hasEmptyParamValue(right))
equalOp := "="
concatOp := "OR"
nullExpr := "IS NULL"
if !equal {
equalOp = "!="
concatOp = "AND"
nullExpr = "IS NOT NULL"
}
// both operands are empty
if isLeftEmpty && isRightEmpty {
return dbx.NewExp(fmt.Sprintf("'' %s ''", equalOp), mergeParams(left.Params, right.Params))
}
// direct compare since at least one of the operands is known to be non-empty
// eg. a = 'example'
if isKnownNonEmptyIdentifier(left) || isKnownNonEmptyIdentifier(right) {
leftIdentifier := left.Identifier
if isLeftEmpty {
leftIdentifier = "''"
}
rightIdentifier := right.Identifier
if isRightEmpty {
rightIdentifier = "''"
}
return dbx.NewExp(
fmt.Sprintf("%s %s %s", leftIdentifier, equalOp, rightIdentifier),
mergeParams(left.Params, right.Params),
)
}
// "" = b OR b IS NULL
// "" != b AND b IS NOT NULL
if isLeftEmpty {
return dbx.NewExp(
fmt.Sprintf("('' %s %s %s %s %s)", equalOp, right.Identifier, concatOp, right.Identifier, nullExpr),
mergeParams(left.Params, right.Params),
)
}
// a = "" OR a IS NULL
// a != "" AND a IS NOT NULL
if isRightEmpty {
return dbx.NewExp(
fmt.Sprintf("(%s %s '' %s %s %s)", left.Identifier, equalOp, concatOp, left.Identifier, nullExpr),
mergeParams(left.Params, right.Params),
)
}
// fallback to a COALESCE comparison
return dbx.NewExp(
fmt.Sprintf(
"COALESCE(%s, '') %s COALESCE(%s, '')",
left.Identifier,
equalOp,
right.Identifier,
),
mergeParams(left.Params, right.Params),
)
}
func hasEmptyParamValue(result *ResolverResult) bool {
for _, p := range result.Params {
switch v := p.(type) {
case nil:
return true
case string:
if v == "" {
return true
}
}
}
return false
}
func isKnownNonEmptyIdentifier(result *ResolverResult) bool {
switch strings.ToLower(result.Identifier) {
case "1", "0", "false", `true`:
return true
}
return len(result.Params) > 0 && !hasEmptyParamValue(result) && !isEmptyIdentifier(result)
}
func isEmptyIdentifier(result *ResolverResult) bool {
switch strings.ToLower(result.Identifier) {
case "", "null", "''", `""`, "``":
return true
default:
return false
}
}
func isAnyMatchOp(op fexpr.SignOp) bool {
switch op {
case
+27 -13
View File
@@ -40,23 +40,32 @@ func TestFilterDataBuildExpr(t *testing.T) {
},
{
"simple expression",
"test1 > 1", false,
"test1 > 1",
false,
"^" +
regexp.QuoteMeta("[[test1]] > {:") +
".+" +
regexp.QuoteMeta("}") +
"$",
},
{
"empty string vs null",
"'' = null && null != ''",
false,
"('' = '' AND '' != '')",
},
{
"like with 2 columns",
"test1 ~ test2", false,
"test1 ~ test2",
false,
"^" +
regexp.QuoteMeta("[[test1]] LIKE ('%' || [[test2]] || '%') ESCAPE '\\'") +
"$",
},
{
"like with right column operand",
"'lorem' ~ test1", false,
"'lorem' ~ test1",
false,
"^" +
regexp.QuoteMeta("{:") +
".+" +
@@ -65,7 +74,8 @@ func TestFilterDataBuildExpr(t *testing.T) {
},
{
"like with left column operand and text as right operand",
"test1 ~ 'lorem'", false,
"test1 ~ 'lorem'",
false,
"^" +
regexp.QuoteMeta("[[test1]] LIKE {:") +
".+" +
@@ -74,14 +84,16 @@ func TestFilterDataBuildExpr(t *testing.T) {
},
{
"not like with 2 columns",
"test1 !~ test2", false,
"test1 !~ test2",
false,
"^" +
regexp.QuoteMeta("[[test1]] NOT LIKE ('%' || [[test2]] || '%') ESCAPE '\\'") +
"$",
},
{
"not like with right column operand",
"'lorem' !~ test1", false,
"'lorem' !~ test1",
false,
"^" +
regexp.QuoteMeta("{:") +
".+" +
@@ -90,7 +102,8 @@ func TestFilterDataBuildExpr(t *testing.T) {
},
{
"like with left column operand and text as right operand",
"test1 !~ 'lorem'", false,
"test1 !~ 'lorem'",
false,
"^" +
regexp.QuoteMeta("[[test1]] NOT LIKE {:") +
".+" +
@@ -99,7 +112,8 @@ func TestFilterDataBuildExpr(t *testing.T) {
},
{
"current datetime constant",
"test1 > @now", false,
"test1 > @now",
false,
"^" +
regexp.QuoteMeta("[[test1]] > {:") +
".+" +
@@ -113,18 +127,18 @@ func TestFilterDataBuildExpr(t *testing.T) {
"^" +
regexp.QuoteMeta("(([[test1]] > {:") +
".+" +
regexp.QuoteMeta("} OR COALESCE([[test2]], '') != COALESCE({:") +
regexp.QuoteMeta("} OR [[test2]] != {:") +
".+" +
regexp.QuoteMeta("}, '')) AND [[test3]] LIKE {:") +
regexp.QuoteMeta("}) AND [[test3]] LIKE {:") +
".+" +
regexp.QuoteMeta("} ESCAPE '\\' AND COALESCE([[test4.sub]], '') = COALESCE(NULL, ''))") +
regexp.QuoteMeta("} ESCAPE '\\' AND ([[test4.sub]] = '' OR [[test4.sub]] IS NULL))") +
"$",
},
{
"combination of special literals (null, true, false)",
"test1=true && test2 != false && test3 = null || test4.sub != null",
"test1=true && test2 != false && null = test3 || null != test4.sub",
false,
"^" + regexp.QuoteMeta("(COALESCE([[test1]], '') = COALESCE(1, '') AND COALESCE([[test2]], '') != COALESCE(0, '') AND COALESCE([[test3]], '') = COALESCE(NULL, '') OR COALESCE([[test4.sub]], '') != COALESCE(NULL, ''))") + "$",
"^" + regexp.QuoteMeta("([[test1]] = 1 AND [[test2]] != 0 AND ('' = [[test3]] OR [[test3]] IS NULL) OR ('' != [[test4.sub]] AND [[test4.sub]] IS NOT NULL))") + "$",
},
{
"all operators",
+4 -4
View File
@@ -274,8 +274,8 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
false,
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2)",
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT 500",
"SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 != '' AND test2 IS NOT NULL)))) AND (test1 >= 2)",
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 != '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT 500",
},
},
{
@@ -287,8 +287,8 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
false,
`{"page":1,"perPage":10,"totalItems":0,"totalPages":0,"items":[]}`,
[]string{
"SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', ''))",
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
"SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 != '' AND test3 IS NOT NULL)))",
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 != '' AND test3 IS NOT NULL))) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
},
},
{
+10 -9
View File
@@ -7,30 +7,31 @@ import (
)
// JsonArray defines a slice that is safe for json and db read/write.
type JsonArray []any
type JsonArray[T any] []T
// internal alias to prevent recursion during marshalization.
type jsonArrayAlias[T any] JsonArray[T]
// MarshalJSON implements the [json.Marshaler] interface.
func (m JsonArray) MarshalJSON() ([]byte, error) {
type alias JsonArray // prevent recursion
func (m JsonArray[T]) MarshalJSON() ([]byte, error) {
// initialize an empty map to ensure that `[]` is returned as json
if m == nil {
m = JsonArray{}
m = JsonArray[T]{}
}
return json.Marshal(alias(m))
return json.Marshal(jsonArrayAlias[T](m))
}
// Value implements the [driver.Valuer] interface.
func (m JsonArray) Value() (driver.Value, error) {
func (m JsonArray[T]) Value() (driver.Value, error) {
data, err := json.Marshal(m)
return string(data), err
}
// Scan implements [sql.Scanner] interface to scan the provided value
// into the current `JsonArray` instance.
func (m *JsonArray) Scan(value any) error {
// into the current JsonArray[T] instance.
func (m *JsonArray[T]) Scan(value any) error {
var data []byte
switch v := value.(type) {
case nil:
+14 -13
View File
@@ -2,6 +2,7 @@ package types_test
import (
"database/sql/driver"
"encoding/json"
"testing"
"github.com/pocketbase/pocketbase/tools/types"
@@ -9,14 +10,14 @@ import (
func TestJsonArrayMarshalJSON(t *testing.T) {
scenarios := []struct {
json types.JsonArray
json json.Marshaler
expected string
}{
{nil, "[]"},
{types.JsonArray{}, `[]`},
{types.JsonArray{1, 2, 3}, `[1,2,3]`},
{types.JsonArray{"test1", "test2", "test3"}, `["test1","test2","test3"]`},
{types.JsonArray{1, "test"}, `[1,"test"]`},
{new(types.JsonArray[any]), "[]"},
{types.JsonArray[any]{}, `[]`},
{types.JsonArray[int]{1, 2, 3}, `[1,2,3]`},
{types.JsonArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`},
{types.JsonArray[any]{1, "test"}, `[1,"test"]`},
}
for i, s := range scenarios {
@@ -33,14 +34,14 @@ func TestJsonArrayMarshalJSON(t *testing.T) {
func TestJsonArrayValue(t *testing.T) {
scenarios := []struct {
json types.JsonArray
json driver.Valuer
expected driver.Value
}{
{nil, `[]`},
{types.JsonArray{}, `[]`},
{types.JsonArray{1, 2, 3}, `[1,2,3]`},
{types.JsonArray{"test1", "test2", "test3"}, `["test1","test2","test3"]`},
{types.JsonArray{1, "test"}, `[1,"test"]`},
{new(types.JsonArray[any]), `[]`},
{types.JsonArray[any]{}, `[]`},
{types.JsonArray[int]{1, 2, 3}, `[1,2,3]`},
{types.JsonArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`},
{types.JsonArray[any]{1, "test"}, `[1,"test"]`},
}
for i, s := range scenarios {
@@ -77,7 +78,7 @@ func TestJsonArrayScan(t *testing.T) {
}
for i, s := range scenarios {
arr := types.JsonArray{}
arr := types.JsonArray[any]{}
scanErr := arr.Scan(s.value)
hasErr := scanErr != nil
-27
View File
@@ -1,27 +0,0 @@
const { exec } = require('node:child_process');
// you can use any other library for copying directories recursively
const fse = require('fs-extra');
let controller; // this will be used to terminate the PocketBase process
const srcTestDirPath = "./test_pb_data";
const tempTestDirPath = "./temp_test_pb_data";
beforeEach(() => {
// copy test_pb_date to a temp location
fse.copySync(srcTestDirPath, tempTestDirPath);
controller = new AbortController();
// start PocketBase with the test_pb_data
exec('./pocketbase serve --dir=' + tempTestDirPath, { signal: controller.signal});
});
afterEach(() => {
// stop the PocketBase process
controller.abort();
// clean up the temp test directory
fse.removeSync(tempTestDirPath);
});
+1 -1
View File
@@ -8,4 +8,4 @@ PB_DOCS_URL = "https://pocketbase.io/docs/"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.13.3"
PB_VERSION = "v0.14.2"
@@ -1,4 +1,4 @@
import{S as ke,i as be,s as ge,e as r,w as b,b as g,c as me,f as k,g as h,h as n,m as _e,x as G,O as re,P as we,k as ve,Q as Ce,n as Pe,t as L,a as Y,o as m,d as pe,R as Me,C as Se,p as $e,r as H,u as je,N as Ae}from"./index-4bd199fb.js";import{S as Be}from"./SdkTabs-fc1a80c5.js";function ue(a,l,o){const s=a.slice();return s[5]=l[o],s}function de(a,l,o){const s=a.slice();return s[5]=l[o],s}function fe(a,l){let o,s=l[5].code+"",_,f,i,u;function d(){return l[4](l[5])}return{key:a,first:null,c(){o=r("button"),_=b(s),f=g(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(v,C){h(v,o,C),n(o,_),n(o,f),i||(u=je(o,"click",d),i=!0)},p(v,C){l=v,C&4&&s!==(s=l[5].code+"")&&G(_,s),C&6&&H(o,"active",l[1]===l[5].code)},d(v){v&&m(o),i=!1,u()}}}function he(a,l){let o,s,_,f;return s=new Ae({props:{content:l[5].body}}),{key:a,first:null,c(){o=r("div"),me(s.$$.fragment),_=g(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(i,u){h(i,o,u),_e(s,o,null),n(o,_),f=!0},p(i,u){l=i;const d={};u&4&&(d.content=l[5].body),s.$set(d),(!f||u&6)&&H(o,"active",l[1]===l[5].code)},i(i){f||(L(s.$$.fragment,i),f=!0)},o(i){Y(s.$$.fragment,i),f=!1},d(i){i&&m(o),pe(s)}}}function Oe(a){var ae,ne;let l,o,s=a[0].name+"",_,f,i,u,d,v,C,F=a[0].name+"",U,R,q,P,D,j,W,M,K,X,Q,A,Z,V,y=a[0].name+"",E,x,I,B,J,S,O,w=[],ee=new Map,te,T,p=[],le=new Map,$;P=new Be({props:{js:`
import{S as ke,i as be,s as ge,e as r,w as g,b as w,c as me,f as k,g as h,h as n,m as _e,x as G,O as re,P as we,k as ve,Q as Ce,n as Pe,t as L,a as Y,o as m,d as pe,T as Me,C as Se,p as $e,r as H,u as je,N as Ae}from"./index-4eea3e34.js";import{S as Be}from"./SdkTabs-5d6cc1d4.js";function ue(a,l,o){const s=a.slice();return s[5]=l[o],s}function de(a,l,o){const s=a.slice();return s[5]=l[o],s}function fe(a,l){let o,s=l[5].code+"",_,f,i,u;function d(){return l[4](l[5])}return{key:a,first:null,c(){o=r("button"),_=g(s),f=w(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(v,C){h(v,o,C),n(o,_),n(o,f),i||(u=je(o,"click",d),i=!0)},p(v,C){l=v,C&4&&s!==(s=l[5].code+"")&&G(_,s),C&6&&H(o,"active",l[1]===l[5].code)},d(v){v&&m(o),i=!1,u()}}}function he(a,l){let o,s,_,f;return s=new Ae({props:{content:l[5].body}}),{key:a,first:null,c(){o=r("div"),me(s.$$.fragment),_=w(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(i,u){h(i,o,u),_e(s,o,null),n(o,_),f=!0},p(i,u){l=i;const d={};u&4&&(d.content=l[5].body),s.$set(d),(!f||u&6)&&H(o,"active",l[1]===l[5].code)},i(i){f||(L(s.$$.fragment,i),f=!0)},o(i){Y(s.$$.fragment,i),f=!1},d(i){i&&m(o),pe(s)}}}function Oe(a){var ae,ne;let l,o,s=a[0].name+"",_,f,i,u,d,v,C,F=a[0].name+"",U,X,q,P,D,j,W,M,K,R,Q,A,Z,V,y=a[0].name+"",E,x,I,B,J,S,O,b=[],ee=new Map,te,T,p=[],le=new Map,$;P=new Be({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${a[3]}');
@@ -14,7 +14,7 @@ import{S as ke,i as be,s as ge,e as r,w as b,b as g,c as me,f as k,g as h,h as n
...
final result = await pb.collection('${(ne=a[0])==null?void 0:ne.name}').listAuthMethods();
`}});let z=a[2];const oe=e=>e[5].code;for(let e=0;e<z.length;e+=1){let t=de(a,z,e),c=oe(t);ee.set(c,w[e]=fe(c,t))}let N=a[2];const se=e=>e[5].code;for(let e=0;e<N.length;e+=1){let t=ue(a,N,e),c=se(t);le.set(c,p[e]=he(c,t))}return{c(){l=r("h3"),o=b("List auth methods ("),_=b(s),f=b(")"),i=g(),u=r("div"),d=r("p"),v=b("Returns a public list with all allowed "),C=r("strong"),U=b(F),R=b(" authentication methods."),q=g(),me(P.$$.fragment),D=g(),j=r("h6"),j.textContent="API details",W=g(),M=r("div"),K=r("strong"),K.textContent="GET",X=g(),Q=r("div"),A=r("p"),Z=b("/api/collections/"),V=r("strong"),E=b(y),x=b("/auth-methods"),I=g(),B=r("div"),B.textContent="Responses",J=g(),S=r("div"),O=r("div");for(let e=0;e<w.length;e+=1)w[e].c();te=g(),T=r("div");for(let e=0;e<p.length;e+=1)p[e].c();k(l,"class","m-b-sm"),k(u,"class","content txt-lg m-b-sm"),k(j,"class","m-b-xs"),k(K,"class","label label-primary"),k(Q,"class","content"),k(M,"class","alert alert-info"),k(B,"class","section-title"),k(O,"class","tabs-header compact left"),k(T,"class","tabs-content"),k(S,"class","tabs")},m(e,t){h(e,l,t),n(l,o),n(l,_),n(l,f),h(e,i,t),h(e,u,t),n(u,d),n(d,v),n(d,C),n(C,U),n(d,R),h(e,q,t),_e(P,e,t),h(e,D,t),h(e,j,t),h(e,W,t),h(e,M,t),n(M,K),n(M,X),n(M,Q),n(Q,A),n(A,Z),n(A,V),n(V,E),n(A,x),h(e,I,t),h(e,B,t),h(e,J,t),h(e,S,t),n(S,O);for(let c=0;c<w.length;c+=1)w[c].m(O,null);n(S,te),n(S,T);for(let c=0;c<p.length;c+=1)p[c].m(T,null);$=!0},p(e,[t]){var ie,ce;(!$||t&1)&&s!==(s=e[0].name+"")&&G(_,s),(!$||t&1)&&F!==(F=e[0].name+"")&&G(U,F);const c={};t&9&&(c.js=`
`}});let z=a[2];const oe=e=>e[5].code;for(let e=0;e<z.length;e+=1){let t=de(a,z,e),c=oe(t);ee.set(c,b[e]=fe(c,t))}let N=a[2];const se=e=>e[5].code;for(let e=0;e<N.length;e+=1){let t=ue(a,N,e),c=se(t);le.set(c,p[e]=he(c,t))}return{c(){l=r("h3"),o=g("List auth methods ("),_=g(s),f=g(")"),i=w(),u=r("div"),d=r("p"),v=g("Returns a public list with all allowed "),C=r("strong"),U=g(F),X=g(" authentication methods."),q=w(),me(P.$$.fragment),D=w(),j=r("h6"),j.textContent="API details",W=w(),M=r("div"),K=r("strong"),K.textContent="GET",R=w(),Q=r("div"),A=r("p"),Z=g("/api/collections/"),V=r("strong"),E=g(y),x=g("/auth-methods"),I=w(),B=r("div"),B.textContent="Responses",J=w(),S=r("div"),O=r("div");for(let e=0;e<b.length;e+=1)b[e].c();te=w(),T=r("div");for(let e=0;e<p.length;e+=1)p[e].c();k(l,"class","m-b-sm"),k(u,"class","content txt-lg m-b-sm"),k(j,"class","m-b-xs"),k(K,"class","label label-primary"),k(Q,"class","content"),k(M,"class","alert alert-info"),k(B,"class","section-title"),k(O,"class","tabs-header compact left"),k(T,"class","tabs-content"),k(S,"class","tabs")},m(e,t){h(e,l,t),n(l,o),n(l,_),n(l,f),h(e,i,t),h(e,u,t),n(u,d),n(d,v),n(d,C),n(C,U),n(d,X),h(e,q,t),_e(P,e,t),h(e,D,t),h(e,j,t),h(e,W,t),h(e,M,t),n(M,K),n(M,R),n(M,Q),n(Q,A),n(A,Z),n(A,V),n(V,E),n(A,x),h(e,I,t),h(e,B,t),h(e,J,t),h(e,S,t),n(S,O);for(let c=0;c<b.length;c+=1)b[c]&&b[c].m(O,null);n(S,te),n(S,T);for(let c=0;c<p.length;c+=1)p[c]&&p[c].m(T,null);$=!0},p(e,[t]){var ie,ce;(!$||t&1)&&s!==(s=e[0].name+"")&&G(_,s),(!$||t&1)&&F!==(F=e[0].name+"")&&G(U,F);const c={};t&9&&(c.js=`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${e[3]}');
@@ -30,7 +30,7 @@ import{S as ke,i as be,s as ge,e as r,w as b,b as g,c as me,f as k,g as h,h as n
...
final result = await pb.collection('${(ce=e[0])==null?void 0:ce.name}').listAuthMethods();
`),P.$set(c),(!$||t&1)&&y!==(y=e[0].name+"")&&G(E,y),t&6&&(z=e[2],w=re(w,t,oe,1,e,z,ee,O,we,fe,null,de)),t&6&&(N=e[2],ve(),p=re(p,t,se,1,e,N,le,T,Ce,he,null,ue),Pe())},i(e){if(!$){L(P.$$.fragment,e);for(let t=0;t<N.length;t+=1)L(p[t]);$=!0}},o(e){Y(P.$$.fragment,e);for(let t=0;t<p.length;t+=1)Y(p[t]);$=!1},d(e){e&&m(l),e&&m(i),e&&m(u),e&&m(q),pe(P,e),e&&m(D),e&&m(j),e&&m(W),e&&m(M),e&&m(I),e&&m(B),e&&m(J),e&&m(S);for(let t=0;t<w.length;t+=1)w[t].d();for(let t=0;t<p.length;t+=1)p[t].d()}}}function Te(a,l,o){let s,{collection:_=new Me}=l,f=200,i=[];const u=d=>o(1,f=d.code);return a.$$set=d=>{"collection"in d&&o(0,_=d.collection)},o(3,s=Se.getApiExampleUrl($e.baseUrl)),o(2,i=[{code:200,body:`
`),P.$set(c),(!$||t&1)&&y!==(y=e[0].name+"")&&G(E,y),t&6&&(z=e[2],b=re(b,t,oe,1,e,z,ee,O,we,fe,null,de)),t&6&&(N=e[2],ve(),p=re(p,t,se,1,e,N,le,T,Ce,he,null,ue),Pe())},i(e){if(!$){L(P.$$.fragment,e);for(let t=0;t<N.length;t+=1)L(p[t]);$=!0}},o(e){Y(P.$$.fragment,e);for(let t=0;t<p.length;t+=1)Y(p[t]);$=!1},d(e){e&&m(l),e&&m(i),e&&m(u),e&&m(q),pe(P,e),e&&m(D),e&&m(j),e&&m(W),e&&m(M),e&&m(I),e&&m(B),e&&m(J),e&&m(S);for(let t=0;t<b.length;t+=1)b[t].d();for(let t=0;t<p.length;t+=1)p[t].d()}}}function Te(a,l,o){let s,{collection:_=new Me}=l,f=200,i=[];const u=d=>o(1,f=d.code);return a.$$set=d=>{"collection"in d&&o(0,_=d.collection)},o(3,s=Se.getApiExampleUrl($e.baseUrl)),o(2,i=[{code:200,body:`
{
"usernamePassword": true,
"emailPassword": true,
@@ -1,4 +1,4 @@
import{S as ze,i as Ue,s as je,N as Ve,e as a,w as k,b as p,c as ae,f as b,g as c,h as o,m as ne,x as re,O as qe,P as xe,k as Je,Q as Ke,n as Qe,t as U,a as j,o as d,d as ie,R as Ie,C as He,p as We,r as x,u as Ge}from"./index-4bd199fb.js";import{S as Xe}from"./SdkTabs-fc1a80c5.js";function Ee(r,l,s){const n=r.slice();return n[5]=l[s],n}function Fe(r,l,s){const n=r.slice();return n[5]=l[s],n}function Le(r,l){let s,n=l[5].code+"",m,_,i,f;function v(){return l[4](l[5])}return{key:r,first:null,c(){s=a("button"),m=k(n),_=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(g,w){c(g,s,w),o(s,m),o(s,_),i||(f=Ge(s,"click",v),i=!0)},p(g,w){l=g,w&4&&n!==(n=l[5].code+"")&&re(m,n),w&6&&x(s,"active",l[1]===l[5].code)},d(g){g&&d(s),i=!1,f()}}}function Ne(r,l){let s,n,m,_;return n=new Ve({props:{content:l[5].body}}),{key:r,first:null,c(){s=a("div"),ae(n.$$.fragment),m=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(i,f){c(i,s,f),ne(n,s,null),o(s,m),_=!0},p(i,f){l=i;const v={};f&4&&(v.content=l[5].body),n.$set(v),(!_||f&6)&&x(s,"active",l[1]===l[5].code)},i(i){_||(U(n.$$.fragment,i),_=!0)},o(i){j(n.$$.fragment,i),_=!1},d(i){i&&d(s),ie(n)}}}function Ye(r){var Be,Me;let l,s,n=r[0].name+"",m,_,i,f,v,g,w,B,J,S,F,ce,L,M,de,K,N=r[0].name+"",Q,ue,pe,V,I,D,W,T,G,fe,X,C,Y,he,Z,be,h,me,P,_e,ke,ve,ee,ge,te,ye,Se,$e,oe,we,le,O,se,R,q,$=[],Te=new Map,Ce,H,y=[],Re=new Map,A;g=new Xe({props:{js:`
import{S as ze,i as Ue,s as je,N as Ve,e as a,w as k,b as p,c as ae,f as b,g as d,h as o,m as ne,x as re,O as qe,P as xe,k as Je,Q as Ke,n as Qe,t as U,a as j,o as u,d as ie,T as Ie,C as He,p as We,r as x,u as Ge}from"./index-4eea3e34.js";import{S as Xe}from"./SdkTabs-5d6cc1d4.js";function Ee(r,l,s){const n=r.slice();return n[5]=l[s],n}function Fe(r,l,s){const n=r.slice();return n[5]=l[s],n}function Le(r,l){let s,n=l[5].code+"",m,_,i,f;function v(){return l[4](l[5])}return{key:r,first:null,c(){s=a("button"),m=k(n),_=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(g,w){d(g,s,w),o(s,m),o(s,_),i||(f=Ge(s,"click",v),i=!0)},p(g,w){l=g,w&4&&n!==(n=l[5].code+"")&&re(m,n),w&6&&x(s,"active",l[1]===l[5].code)},d(g){g&&u(s),i=!1,f()}}}function Ne(r,l){let s,n,m,_;return n=new Ve({props:{content:l[5].body}}),{key:r,first:null,c(){s=a("div"),ae(n.$$.fragment),m=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(i,f){d(i,s,f),ne(n,s,null),o(s,m),_=!0},p(i,f){l=i;const v={};f&4&&(v.content=l[5].body),n.$set(v),(!_||f&6)&&x(s,"active",l[1]===l[5].code)},i(i){_||(U(n.$$.fragment,i),_=!0)},o(i){j(n.$$.fragment,i),_=!1},d(i){i&&u(s),ie(n)}}}function Ye(r){var Be,Me;let l,s,n=r[0].name+"",m,_,i,f,v,g,w,B,J,$,F,ce,L,M,de,K,N=r[0].name+"",Q,ue,pe,V,I,D,W,T,G,fe,X,C,Y,he,Z,be,h,me,R,_e,ke,ve,ee,ge,te,ye,Se,$e,oe,we,le,O,se,P,q,S=[],Te=new Map,Ce,H,y=[],Pe=new Map,A;g=new Xe({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${r[3]}');
@@ -24,18 +24,18 @@ import{S as ze,i as Ue,s as je,N as Ve,e as a,w as k,b as p,c as ae,f as b,g as
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.model.id);
`}}),P=new Ve({props:{content:"?expand=relField1,relField2.subRelField"}});let z=r[2];const Pe=e=>e[5].code;for(let e=0;e<z.length;e+=1){let t=Fe(r,z,e),u=Pe(t);Te.set(u,$[e]=Le(u,t))}let E=r[2];const Ae=e=>e[5].code;for(let e=0;e<E.length;e+=1){let t=Ee(r,E,e),u=Ae(t);Re.set(u,y[e]=Ne(u,t))}return{c(){l=a("h3"),s=k("Auth refresh ("),m=k(n),_=k(")"),i=p(),f=a("div"),f.innerHTML=`<p>Returns a new auth response (token and record data) for an
`}}),R=new Ve({props:{content:"?expand=relField1,relField2.subRelField"}});let z=r[2];const Re=e=>e[5].code;for(let e=0;e<z.length;e+=1){let t=Fe(r,z,e),c=Re(t);Te.set(c,S[e]=Le(c,t))}let E=r[2];const Ae=e=>e[5].code;for(let e=0;e<E.length;e+=1){let t=Ee(r,E,e),c=Ae(t);Pe.set(c,y[e]=Ne(c,t))}return{c(){l=a("h3"),s=k("Auth refresh ("),m=k(n),_=k(")"),i=p(),f=a("div"),f.innerHTML=`<p>Returns a new auth response (token and record data) for an
<strong>already authenticated record</strong>.</p>
<p><em>This method is usually called by users on page/screen reload to ensure that the previously stored
data in <code>pb.authStore</code> is still valid and up-to-date.</em></p>`,v=p(),ae(g.$$.fragment),w=p(),B=a("h6"),B.textContent="API details",J=p(),S=a("div"),F=a("strong"),F.textContent="POST",ce=p(),L=a("div"),M=a("p"),de=k("/api/collections/"),K=a("strong"),Q=k(N),ue=k("/auth-refresh"),pe=p(),V=a("p"),V.innerHTML="Requires record <code>Authorization:TOKEN</code> header",I=p(),D=a("div"),D.textContent="Query parameters",W=p(),T=a("table"),G=a("thead"),G.innerHTML=`<tr><th>Param</th>
data in <code>pb.authStore</code> is still valid and up-to-date.</em></p>`,v=p(),ae(g.$$.fragment),w=p(),B=a("h6"),B.textContent="API details",J=p(),$=a("div"),F=a("strong"),F.textContent="POST",ce=p(),L=a("div"),M=a("p"),de=k("/api/collections/"),K=a("strong"),Q=k(N),ue=k("/auth-refresh"),pe=p(),V=a("p"),V.innerHTML="Requires record <code>Authorization:TOKEN</code> header",I=p(),D=a("div"),D.textContent="Query parameters",W=p(),T=a("table"),G=a("thead"),G.innerHTML=`<tr><th>Param</th>
<th>Type</th>
<th width="60%">Description</th></tr>`,fe=p(),X=a("tbody"),C=a("tr"),Y=a("td"),Y.textContent="expand",he=p(),Z=a("td"),Z.innerHTML='<span class="label">String</span>',be=p(),h=a("td"),me=k(`Auto expand record relations. Ex.:
`),ae(P.$$.fragment),_e=k(`
`),ae(R.$$.fragment),_e=k(`
Supports up to 6-levels depth nested relations expansion. `),ke=a("br"),ve=k(`
The expanded relations will be appended to the record under the
`),ee=a("code"),ee.textContent="expand",ge=k(" property (eg. "),te=a("code"),te.textContent='"expand": {"relField1": {...}, ...}',ye=k(`).
`),Se=a("br"),$e=k(`
Only the relations to which the request user has permissions to `),oe=a("strong"),oe.textContent="view",we=k(" will be expanded."),le=p(),O=a("div"),O.textContent="Responses",se=p(),R=a("div"),q=a("div");for(let e=0;e<$.length;e+=1)$[e].c();Ce=p(),H=a("div");for(let e=0;e<y.length;e+=1)y[e].c();b(l,"class","m-b-sm"),b(f,"class","content txt-lg m-b-sm"),b(B,"class","m-b-xs"),b(F,"class","label label-primary"),b(L,"class","content"),b(V,"class","txt-hint txt-sm txt-right"),b(S,"class","alert alert-success"),b(D,"class","section-title"),b(T,"class","table-compact table-border m-b-base"),b(O,"class","section-title"),b(q,"class","tabs-header compact left"),b(H,"class","tabs-content"),b(R,"class","tabs")},m(e,t){c(e,l,t),o(l,s),o(l,m),o(l,_),c(e,i,t),c(e,f,t),c(e,v,t),ne(g,e,t),c(e,w,t),c(e,B,t),c(e,J,t),c(e,S,t),o(S,F),o(S,ce),o(S,L),o(L,M),o(M,de),o(M,K),o(K,Q),o(M,ue),o(S,pe),o(S,V),c(e,I,t),c(e,D,t),c(e,W,t),c(e,T,t),o(T,G),o(T,fe),o(T,X),o(X,C),o(C,Y),o(C,he),o(C,Z),o(C,be),o(C,h),o(h,me),ne(P,h,null),o(h,_e),o(h,ke),o(h,ve),o(h,ee),o(h,ge),o(h,te),o(h,ye),o(h,Se),o(h,$e),o(h,oe),o(h,we),c(e,le,t),c(e,O,t),c(e,se,t),c(e,R,t),o(R,q);for(let u=0;u<$.length;u+=1)$[u].m(q,null);o(R,Ce),o(R,H);for(let u=0;u<y.length;u+=1)y[u].m(H,null);A=!0},p(e,[t]){var De,Oe;(!A||t&1)&&n!==(n=e[0].name+"")&&re(m,n);const u={};t&9&&(u.js=`
Only the relations to which the request user has permissions to `),oe=a("strong"),oe.textContent="view",we=k(" will be expanded."),le=p(),O=a("div"),O.textContent="Responses",se=p(),P=a("div"),q=a("div");for(let e=0;e<S.length;e+=1)S[e].c();Ce=p(),H=a("div");for(let e=0;e<y.length;e+=1)y[e].c();b(l,"class","m-b-sm"),b(f,"class","content txt-lg m-b-sm"),b(B,"class","m-b-xs"),b(F,"class","label label-primary"),b(L,"class","content"),b(V,"class","txt-hint txt-sm txt-right"),b($,"class","alert alert-success"),b(D,"class","section-title"),b(T,"class","table-compact table-border m-b-base"),b(O,"class","section-title"),b(q,"class","tabs-header compact left"),b(H,"class","tabs-content"),b(P,"class","tabs")},m(e,t){d(e,l,t),o(l,s),o(l,m),o(l,_),d(e,i,t),d(e,f,t),d(e,v,t),ne(g,e,t),d(e,w,t),d(e,B,t),d(e,J,t),d(e,$,t),o($,F),o($,ce),o($,L),o(L,M),o(M,de),o(M,K),o(K,Q),o(M,ue),o($,pe),o($,V),d(e,I,t),d(e,D,t),d(e,W,t),d(e,T,t),o(T,G),o(T,fe),o(T,X),o(X,C),o(C,Y),o(C,he),o(C,Z),o(C,be),o(C,h),o(h,me),ne(R,h,null),o(h,_e),o(h,ke),o(h,ve),o(h,ee),o(h,ge),o(h,te),o(h,ye),o(h,Se),o(h,$e),o(h,oe),o(h,we),d(e,le,t),d(e,O,t),d(e,se,t),d(e,P,t),o(P,q);for(let c=0;c<S.length;c+=1)S[c]&&S[c].m(q,null);o(P,Ce),o(P,H);for(let c=0;c<y.length;c+=1)y[c]&&y[c].m(H,null);A=!0},p(e,[t]){var De,Oe;(!A||t&1)&&n!==(n=e[0].name+"")&&re(m,n);const c={};t&9&&(c.js=`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${e[3]}');
@@ -48,7 +48,7 @@ import{S as ze,i as Ue,s as je,N as Ve,e as a,w as k,b as p,c as ae,f as b,g as
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.model.id);
`),t&9&&(u.dart=`
`),t&9&&(c.dart=`
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${e[3]}');
@@ -61,7 +61,7 @@ import{S as ze,i as Ue,s as je,N as Ve,e as a,w as k,b as p,c as ae,f as b,g as
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.model.id);
`),g.$set(u),(!A||t&1)&&N!==(N=e[0].name+"")&&re(Q,N),t&6&&(z=e[2],$=qe($,t,Pe,1,e,z,Te,q,xe,Le,null,Fe)),t&6&&(E=e[2],Je(),y=qe(y,t,Ae,1,e,E,Re,H,Ke,Ne,null,Ee),Qe())},i(e){if(!A){U(g.$$.fragment,e),U(P.$$.fragment,e);for(let t=0;t<E.length;t+=1)U(y[t]);A=!0}},o(e){j(g.$$.fragment,e),j(P.$$.fragment,e);for(let t=0;t<y.length;t+=1)j(y[t]);A=!1},d(e){e&&d(l),e&&d(i),e&&d(f),e&&d(v),ie(g,e),e&&d(w),e&&d(B),e&&d(J),e&&d(S),e&&d(I),e&&d(D),e&&d(W),e&&d(T),ie(P),e&&d(le),e&&d(O),e&&d(se),e&&d(R);for(let t=0;t<$.length;t+=1)$[t].d();for(let t=0;t<y.length;t+=1)y[t].d()}}}function Ze(r,l,s){let n,{collection:m=new Ie}=l,_=200,i=[];const f=v=>s(1,_=v.code);return r.$$set=v=>{"collection"in v&&s(0,m=v.collection)},r.$$.update=()=>{r.$$.dirty&1&&s(2,i=[{code:200,body:JSON.stringify({token:"JWT_TOKEN",record:He.dummyCollectionRecord(m)},null,2)},{code:401,body:`
`),g.$set(c),(!A||t&1)&&N!==(N=e[0].name+"")&&re(Q,N),t&6&&(z=e[2],S=qe(S,t,Re,1,e,z,Te,q,xe,Le,null,Fe)),t&6&&(E=e[2],Je(),y=qe(y,t,Ae,1,e,E,Pe,H,Ke,Ne,null,Ee),Qe())},i(e){if(!A){U(g.$$.fragment,e),U(R.$$.fragment,e);for(let t=0;t<E.length;t+=1)U(y[t]);A=!0}},o(e){j(g.$$.fragment,e),j(R.$$.fragment,e);for(let t=0;t<y.length;t+=1)j(y[t]);A=!1},d(e){e&&u(l),e&&u(i),e&&u(f),e&&u(v),ie(g,e),e&&u(w),e&&u(B),e&&u(J),e&&u($),e&&u(I),e&&u(D),e&&u(W),e&&u(T),ie(R),e&&u(le),e&&u(O),e&&u(se),e&&u(P);for(let t=0;t<S.length;t+=1)S[t].d();for(let t=0;t<y.length;t+=1)y[t].d()}}}function Ze(r,l,s){let n,{collection:m=new Ie}=l,_=200,i=[];const f=v=>s(1,_=v.code);return r.$$set=v=>{"collection"in v&&s(0,m=v.collection)},r.$$.update=()=>{r.$$.dirty&1&&s(2,i=[{code:200,body:JSON.stringify({token:"JWT_TOKEN",record:He.dummyCollectionRecord(m)},null,2)},{code:401,body:`
{
"code": 401,
"message": "The request requires valid record authorization token to be set.",

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