From 81b656e005f4c45b78d6d3bde4c410a66634a7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B6ttgers=20Carsten?= Date: Thu, 30 Nov 2023 07:41:44 +0100 Subject: [PATCH 01/11] Start implementing OData V2 (work in progress) --- README.md | 3 +- package.json | 2 +- pkg/plugin/client.go | 6 + pkg/plugin/datasource.go | 49 ++++-- pkg/plugin/mocks_test.go | 4 + pkg/plugin/odata/functions.go | 42 ++++- pkg/plugin/odata/models.go | 8 + provisioning/datasources/datasource.yml | 13 +- src/components/ConfigEditor.tsx | 37 ++++- src/types.ts | 6 + test-server/README.md | 7 +- test-server/examples/data_v2.json | 18 +++ test-server/examples/data_v4.json | 14 ++ test-server/examples/metadata_v2.xml | 46 ++++++ test-server/examples/metadata_v4.xml | 46 ++++++ test-server/package.json | 1 + test-server/srv/server.ts | 2 + test-server/yarn.lock | 195 +++++++++++++++++++++++- 18 files changed, 478 insertions(+), 21 deletions(-) create mode 100644 test-server/examples/data_v2.json create mode 100644 test-server/examples/data_v4.json create mode 100644 test-server/examples/metadata_v2.xml create mode 100644 test-server/examples/metadata_v4.xml diff --git a/README.md b/README.md index a42741bb..ddabe18b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Visualize data from OData data sources with Grafana. ## About -This is a Grafana data source for showing data from OData V4 compliant data sources. +This is a Grafana data source for showing data from OData V2 and V4 compliant data sources. The plugin currently only +supports XML format for metadata (`$metadata`) and JSON format for payload data. It was originally developed for internal purposes and is now made available to the open source community. diff --git a/package.json b/package.json index 998af093..2eed632a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "grafana-odata-datasource", "version": "1.0.0", - "description": "Loads data from OData (V4) compliant data sources to Grafana", + "description": "Loads data from OData (V2 and V4) compliant data sources to Grafana", "bugs": { "url": "https://github.com/d-velop/grafana-odata-datasource/issues" }, diff --git a/pkg/plugin/client.go b/pkg/plugin/client.go index 7a86581b..a7f35676 100644 --- a/pkg/plugin/client.go +++ b/pkg/plugin/client.go @@ -13,6 +13,7 @@ import ( ) type ODataClient interface { + ODataVersion() string GetServiceRoot() (*http.Response, error) GetMetadata() (*http.Response, error) Get(entitySet string, properties []property, timeProperty string, timeRange backend.TimeRange, @@ -23,6 +24,11 @@ type ODataClientImpl struct { httpClient *http.Client baseUrl string urlSpaceEncoding string + odataVersion string +} + +func (client *ODataClientImpl) ODataVersion() string { + return client.odataVersion } func (client *ODataClientImpl) GetServiceRoot() (*http.Response, error) { diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index e84e8eea..359811ca 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -30,6 +30,7 @@ type ODataSource struct { type DatasourceSettings struct { URLSpaceEncoding string `json:"urlSpaceEncoding"` + ODataVersion string `json:"odataVersion"` } func newDatasourceInstance(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { @@ -50,7 +51,7 @@ func newDatasourceInstance(ctx context.Context, settings backend.DataSourceInsta } return &ODataSourceInstance{ - &ODataClientImpl{client, settings.URL, dsSettings.URLSpaceEncoding}, + &ODataClientImpl{client, settings.URL, dsSettings.URLSpaceEncoding, dsSettings.ODataVersion}, }, nil } @@ -120,6 +121,24 @@ func (ds *ODataSource) CallResource(ctx context.Context, req *backend.CallResour } } +func mapToV4Response(bodyBytes []byte) ([]map[string]interface{}, error) { + var response odata.Response + err := json.Unmarshal(bodyBytes, &response) + if err != nil { + return nil, err + } + return response.Value, nil +} + +func mapToV2Response(bodyBytes []byte) ([]map[string]interface{}, error) { + var response odata.ResponseV2 + err := json.Unmarshal(bodyBytes, &response) + if err != nil { + return nil, err + } + return response.D.Results, nil +} + func (ds *ODataSource) query(clientInstance ODataClient, query backend.DataQuery) backend.DataResponse { log.DefaultLogger.Debug("query", "query.JSON", string(query.JSON)) response := backend.DataResponse{} @@ -168,18 +187,26 @@ func (ds *ODataSource) query(clientInstance ODataClient, query backend.DataQuery response.Error = err return response } - var result odata.Response - err = json.Unmarshal(bodyBytes, &result) - if err != nil { - response.Error = err - return response + version := clientInstance.ODataVersion() + log.DefaultLogger.Debug("using odata version", "version", version) + var values []map[string]interface{} + if version == "V2" { + values, err = mapToV2Response(bodyBytes) + if err != nil { + response.Error = err + return response + } + } else { + values, err = mapToV4Response(bodyBytes) + if err != nil { + response.Error = err + return response + } } - - log.DefaultLogger.Debug("query complete", "noOfEntities", len(result.Value)) - - for _, entry := range result.Value { + log.DefaultLogger.Debug("query complete", "noOfEntities", len(values)) + for _, entry := range values { values := make([]interface{}, len(qm.Properties)+1) - if timeValue, err := time.Parse(time.RFC3339Nano, fmt.Sprint(entry[timeProperty])); err == nil { + if timeValue, err := odata.ParseTime(fmt.Sprint(entry[timeProperty]), version); err == nil { values[0] = &timeValue } else { values[0] = nil diff --git a/pkg/plugin/mocks_test.go b/pkg/plugin/mocks_test.go index 81bb66e5..40a4ac42 100644 --- a/pkg/plugin/mocks_test.go +++ b/pkg/plugin/mocks_test.go @@ -27,6 +27,10 @@ type callResourceResponseSenderMock struct { csr *backend.CallResourceResponse } +func (client *clientMock) ODataVersion() string { + return "V4" +} + func (client *clientMock) GetServiceRoot() (*http.Response, error) { return &http.Response{StatusCode: client.statusCode, Body: io.NopCloser(bytes.NewReader(client.body))}, client.err diff --git a/pkg/plugin/odata/functions.go b/pkg/plugin/odata/functions.go index baaf120a..d81c6e1e 100644 --- a/pkg/plugin/odata/functions.go +++ b/pkg/plugin/odata/functions.go @@ -1,6 +1,11 @@ package odata -import "fmt" +import ( + "fmt" + "strconv" + "strings" + "time" +) func ToArray(propertyType string) interface{} { switch propertyType { @@ -27,6 +32,26 @@ func ToArray(propertyType string) interface{} { } } +func ParseTime(timeString string, version string) (time.Time, error) { + if version == "V2" { + // TODO: work in progress + trimmed := strings.TrimPrefix(timeString, "/Date(") + trimmed = strings.TrimSuffix(trimmed, ")/") + parts := strings.Split(trimmed, "+") + if len(parts) != 2 { + return time.Time{}, fmt.Errorf("invalid format") + } + ms, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return time.Time{}, err + } + seconds := ms / 1000 + nanoseconds := (ms % 1000) * 1000000 + return time.Unix(seconds, nanoseconds), nil + } + return time.Parse(time.RFC3339Nano, timeString) +} + func MapValue(value interface{}, propertyType string) interface{} { if value == nil { return nil @@ -36,7 +61,20 @@ func MapValue(value interface{}, propertyType string) interface{} { boolValue := value.(bool) return &boolValue case EdmSingle, EdmDecimal, EdmDouble, EdmSByte, EdmByte, EdmInt16, EdmInt32, EdmInt64: - return mapNumber(value.(float64), propertyType) + if stringValue, ok := value.(string); ok { + // TODO: work in progress + floatValue, err := strconv.ParseFloat(stringValue, 64) + if err != nil { + panic("could not parse number value in string") + } + return mapNumber(floatValue, propertyType) + } else if floatValue, ok := value.(float64); ok { + return mapNumber(floatValue, propertyType) + } else { + // TODO: fall back to string? + x := fmt.Sprint(value) + return &x + } default: x := fmt.Sprint(value) return &x diff --git a/pkg/plugin/odata/models.go b/pkg/plugin/odata/models.go index efb51d6f..098acd41 100644 --- a/pkg/plugin/odata/models.go +++ b/pkg/plugin/odata/models.go @@ -27,6 +27,14 @@ type Response struct { Value []map[string]interface{} `json:"value"` } +type ResponseV2 struct { + D ResultsV2 `json:"d"` +} + +type ResultsV2 struct { + Results []map[string]interface{} `json:"results"` +} + type Edmx struct { XMLName xml.Name `xml:"Edmx"` Version string `xml:"Version,attr"` diff --git a/provisioning/datasources/datasource.yml b/provisioning/datasources/datasource.yml index af788179..2ed37fdd 100644 --- a/provisioning/datasources/datasource.yml +++ b/provisioning/datasources/datasource.yml @@ -10,7 +10,6 @@ datasources: - name: 'OData-Test' uid: PD461F79B494E0A01 type: dvelop-odata-datasource - access: proxy orgId: 1 isDefault: true url: http://test-server:4004/odata/v4/test @@ -21,9 +20,19 @@ datasources: - name: 'OData-Mock' uid: PD461F79B494E0A02 type: dvelop-odata-datasource - access: proxy orgId: 1 isDefault: false url: http://test-server:4004/mock version: 1 editable: true + - name: 'OData-Test-V2' + uid: PD461F79B494E0A03 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: http://test-server:4004/v2/odata/v4/test + version: 1 + editable: true + jsonData: + urlSpaceEncoding: '%20' + odataVersion: 'V2' diff --git a/src/components/ConfigEditor.tsx b/src/components/ConfigEditor.tsx index 48e99a91..8f9a1d69 100644 --- a/src/components/ConfigEditor.tsx +++ b/src/components/ConfigEditor.tsx @@ -4,7 +4,7 @@ import { } from '@grafana/data'; import {DataSourceHttpSettings, FieldSet, InlineField, InlineFieldRow, Select} from '@grafana/ui'; import React, {ComponentType, useCallback} from 'react'; -import {ODataOptions, URLSpaceEncoding} from '../types'; +import {ODataOptions, URLSpaceEncoding, ODataVersion} from '../types'; type Props = DataSourcePluginOptionsEditorProps; @@ -23,6 +23,20 @@ export const ConfigEditor: ComponentType = ({ options, onOptionsChange }) const urlSpaceEncodings = Object.entries(URLSpaceEncoding) .map(([label, value]) => ({ label: `${label} (${value})`, value: value })); + const onODataVersionChange = useCallback((option: SelectableValue) => { + const odataVersion = option.value; + onOptionsChange({ + ...options, + jsonData: { + ...options.jsonData, + odataVersion: odataVersion || 'V4', + }, + }); + }, [onOptionsChange, options]); + + const odataVersions = Object.entries(ODataVersion) + .map(([label, value]) => ({ label: label, value: value })); + return ( <> = ({ options, onOptionsChange })

Additional settings

+ + + Select the OData version, currently V2 and V4 (default) are supported. The plugin currently only + supports XML format for metadata (`$metadata`) and JSON format for payload data for both OData + versions. +

+ }> + Date: Mon, 11 Dec 2023 07:22:24 +0100 Subject: [PATCH 07/11] WIP --- pkg/plugin/datasource.go | 31 +++++---------- pkg/plugin/odata/functions.go | 24 ++++++++++++ pkg/plugin/odata/functions_test.go | 61 ++++++++++++++++++++++++++++-- pkg/plugin/odata/models.go | 10 ++--- 4 files changed, 95 insertions(+), 31 deletions(-) diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index eb7d23b7..caddbc97 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -122,25 +122,6 @@ func (ds *ODataSource) CallResource(ctx context.Context, req *backend.CallResour } } -func mapToResponse(bodyBytes []byte) ([]map[string]interface{}, error) { - var response odata.Response - err := json.Unmarshal(bodyBytes, &response) - if err != nil { - return nil, err - } - var result []map[string]interface{} - if response.Value != nil { - result = response.Value - } else if response.D != nil && response.D.Results != nil { - result = response.D.Results - } else if response.Results != nil { - result = response.Results - } else { - // TODO: return nil, fmt.Errorf("error mapping response: unrecognized response format") - } - return result, nil -} - func (ds *ODataSource) query(clientInstance ODataClient, query backend.DataQuery) backend.DataResponse { log.DefaultLogger.Debug("query", "query.JSON", string(query.JSON)) response := backend.DataResponse{} @@ -210,7 +191,7 @@ func (ds *ODataSource) query(clientInstance ODataClient, query backend.DataQuery } } log.DefaultLogger.Debug("using odata version", "version", version) - entries, err := mapToResponse(bodyBytes) + entries, err := odata.MapToResponse(bodyBytes) if err != nil { response.Error = err return response @@ -218,13 +199,19 @@ func (ds *ODataSource) query(clientInstance ODataClient, query backend.DataQuery log.DefaultLogger.Debug("query complete", "noOfEntities", len(entries)) for _, entry := range entries { values := make([]interface{}, len(qm.Properties)+1) - if timeValue, err := odata.ParseTime(fmt.Sprint(entry[timeProperty.Name])); err == nil { + object, ok := entry.(map[string]interface{}) + if !ok { + // TODO: error handling + continue + } + // TODO: guess time format only once! + if timeValue, err := odata.ParseTime(fmt.Sprint(object[timeProperty.Name])); err == nil { values[0] = &timeValue } else { values[0] = nil } for i, prop := range qm.Properties { - if value, ok := entry[prop.Name]; ok { + if value, ok := object[prop.Name]; ok { values[i+1] = odata.MapValue(value, prop.Type) } else { values[i+1] = nil diff --git a/pkg/plugin/odata/functions.go b/pkg/plugin/odata/functions.go index f11795aa..24417cb1 100644 --- a/pkg/plugin/odata/functions.go +++ b/pkg/plugin/odata/functions.go @@ -1,6 +1,7 @@ package odata import ( + "encoding/json" "fmt" "strconv" "strings" @@ -158,3 +159,26 @@ func mapNumber(value float64, propertyType string) interface{} { panic("unexpected property type") } } + +func MapToResponse(bodyBytes []byte) ([]interface{}, error) { + var response Response + err := json.Unmarshal(bodyBytes, &response) + if err != nil { + return nil, err + } + if response.Value != nil { + return response.Value, nil + } else if response.D != nil { + switch d := response.D.(type) { + case map[string]interface{}: + if results, ok := d["results"].([]interface{}); ok { + return results, nil + } + case []interface{}: + return d, nil + } + } else if response.Results != nil { + return response.Results, nil + } + return nil, nil +} diff --git a/pkg/plugin/odata/functions_test.go b/pkg/plugin/odata/functions_test.go index c434f701..5d89e42d 100644 --- a/pkg/plugin/odata/functions_test.go +++ b/pkg/plugin/odata/functions_test.go @@ -1,7 +1,7 @@ package odata import ( - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" "time" ) @@ -61,10 +61,63 @@ func TestParseTime(t *testing.T) { }, } for _, tc := range testCases { - t.Run(tc.input, func(t *testing.T) { + t.Run(tc.name+": "+tc.input, func(t *testing.T) { ts, err := ParseTime(tc.input) - assert.NoError(t, err) - assert.Equal(t, tc.expectedTime.UTC(), ts.UTC()) + require.NoError(t, err) + require.Equal(t, tc.expectedTime.UTC(), ts.UTC()) + }) + } +} + +func TestResponseMapping(t *testing.T) { + testCases := []struct { + name string + input string + }{ + { + name: "", + input: `{ + "value": [ + {"test": "test"} + ] + }`, + }, + { + name: "", + input: `{ + "d": { + "results": [ + {"test": "test"} + ] + } + }`, + }, + { + name: "", + input: `{ + "d": [ + {"test": "test"} + ] + }`, + }, + { + name: "", + input: `{ + "results": [ + {"test": "test"} + ] + }`, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + entries, err := MapToResponse([]byte(tc.input)) + require.NoError(t, err) + for _, entry := range entries { + object, ok := entry.(map[string]interface{}) + require.Equal(t, true, ok) + require.Equal(t, "test", object["test"]) + } }) } } diff --git a/pkg/plugin/odata/models.go b/pkg/plugin/odata/models.go index 4d818331..04a207a1 100644 --- a/pkg/plugin/odata/models.go +++ b/pkg/plugin/odata/models.go @@ -24,12 +24,12 @@ const ( Select = "$select" ) +// Response represents different response formats from OData V2 to V4 and different implementations. It is used to +// autodetect and extract the right payload. type Response struct { - D *struct { - Results []map[string]interface{} `json:"results"` - } `json:"d"` - Results []map[string]interface{} `json:"results"` - Value []map[string]interface{} `json:"value"` + D interface{} `json:"d"` + Results []interface{} `json:"results"` + Value []interface{} `json:"value"` } type Edmx struct { From f7a71aeba7ed6205233fece30ed804812f8c6712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B6ttgers=20Carsten?= Date: Fri, 4 Apr 2025 16:35:36 +0200 Subject: [PATCH 08/11] WIP --- pkg/plugin/datasource.go | 8 +- pkg/plugin/odata/functions.go | 51 +- .../dashboards/ref_time_dashboard.json | 511 ++++++++++++++++++ .../dashboards/reference_dashboard.json | 478 ++++++++++++++++ provisioning/dashboards/test_dashboard.json | 232 +++++++- .../datasources/reference_datasources.yml | 10 + test-server/package.json | 4 +- test-server/yarn.lock | 10 +- 8 files changed, 1259 insertions(+), 45 deletions(-) create mode 100644 provisioning/dashboards/ref_time_dashboard.json create mode 100644 provisioning/dashboards/reference_dashboard.json diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index a1577d83..8e5cce1a 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -207,7 +207,9 @@ func (ds *ODataSource) query(clientInstance ODataClient, query backend.DataQuery response.Error = err return response } + log.DefaultLogger.Debug("query complete", "noOfEntities", len(entries)) + for _, entry := range entries { var values []interface{} object, ok := entry.(map[string]interface{}) @@ -215,14 +217,8 @@ func (ds *ODataSource) query(clientInstance ODataClient, query backend.DataQuery // TODO: error handling continue } - // TODO: guess time format only once! if qm.TimeProperty != nil { values = make([]interface{}, len(qm.Properties)+1) - /* - if timeValue, err := odata.ParseTime(fmt.Sprint(object[qm.TimeProperty.Name])); err == nil { - values[0] = timeValue - } - */ values[0] = odata.MapValue(object[qm.TimeProperty.Name], qm.TimeProperty.Type) } else { values = make([]interface{}, len(qm.Properties)) diff --git a/pkg/plugin/odata/functions.go b/pkg/plugin/odata/functions.go index 5cf96895..a3c25f35 100644 --- a/pkg/plugin/odata/functions.go +++ b/pkg/plugin/odata/functions.go @@ -31,9 +31,7 @@ func ToArray(propertyType string) interface{} { return []*int32{} case EdmInt64: return []*int64{} - case EdmDateTimeOffset: - return []*time.Time{} - case EdmDate: + case EdmDateTimeOffset, EdmDateTime, EdmDate: return []*time.Time{} default: return []*string{} @@ -87,7 +85,7 @@ func parseV2Time(timeString string) (time.Time, error) { return result.In(loc), nil } -func ParseTime(timeString string) (time.Time, error) { +func parseTime(timeString string) (time.Time, error) { if strings.HasPrefix(timeString, "/") { ts, err := parseV2Time(timeString) if err == nil && !ts.IsZero() { @@ -119,22 +117,14 @@ func MapValue(value interface{}, propertyType string) interface{} { boolValue := value.(bool) return &boolValue case EdmSingle, EdmDecimal, EdmDouble, EdmSByte, EdmByte, EdmInt16, EdmInt32, EdmInt64: - if stringValue, ok := value.(string); ok { - // TODO: work in progress - floatValue, err := strconv.ParseFloat(stringValue, 64) - if err != nil { - panic("could not parse number value in string") - } - return mapNumber(floatValue, propertyType) - } else if floatValue, ok := value.(float64); ok { - return mapNumber(floatValue, propertyType) - } else { - // TODO: fall back to string? - x := fmt.Sprint(value) - return &x + floatValue, err := toFloat64(value) + if err != nil { + fmt.Printf("ERROR: Expected a numeric type but got %T with value %v\n", value, value) + return nil } - case EdmDateTimeOffset, EdmDate: - if timeValue, err := time.Parse(time.RFC3339Nano, fmt.Sprint(value)); err == nil { + return mapNumber(floatValue, propertyType) + case EdmDateTimeOffset, EdmDateTime, EdmDate: + if timeValue, err := parseTime(fmt.Sprint(value)); err == nil { return &timeValue } else { return nil @@ -145,6 +135,29 @@ func MapValue(value interface{}, propertyType string) interface{} { } } +func toFloat64(value interface{}) (float64, error) { + switch v := value.(type) { + case float64: + return v, nil + case float32: + return float64(v), nil + case int: + return float64(v), nil + case int64: + return float64(v), nil + case int32: + return float64(v), nil + case int16: + return float64(v), nil + case int8: + return float64(v), nil + case string: + return strconv.ParseFloat(v, 64) + default: + return 0, fmt.Errorf("cannot convert %T to float64", value) + } +} + func mapNumber(value float64, propertyType string) interface{} { switch propertyType { case EdmSingle: diff --git a/provisioning/dashboards/ref_time_dashboard.json b/provisioning/dashboards/ref_time_dashboard.json new file mode 100644 index 00000000..96191b31 --- /dev/null +++ b/provisioning/dashboards/ref_time_dashboard.json @@ -0,0 +1,511 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B04" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 4, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B04" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTimeOffset" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "ReleaseDate", + "type": "Edm.DateTimeOffset" + } + } + ], + "title": "Products table (Reference-V4)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B03" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B03" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTime" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "ReleaseDate", + "type": "Edm.DateTime" + } + } + ], + "title": "Orders table (Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "entitySet": { + "entityType": "NorthwindModel.Order", + "name": "Orders" + }, + "properties": [ + { + "name": "OrderID", + "type": "Edm.Int32" + }, + { + "name": "CustomerID", + "type": "Edm.String" + }, + { + "name": "EmployeeID", + "type": "Edm.Int32" + }, + { + "name": "RequiredDate", + "type": "Edm.DateTime" + }, + { + "name": "ShippedDate", + "type": "Edm.DateTime" + }, + { + "name": "ShipVia", + "type": "Edm.Int32" + }, + { + "name": "Freight", + "type": "Edm.Decimal" + }, + { + "name": "ShipName", + "type": "Edm.String" + }, + { + "name": "ShipAddress", + "type": "Edm.String" + }, + { + "name": "ShipCity", + "type": "Edm.String" + }, + { + "name": "ShipRegion", + "type": "Edm.String" + }, + { + "name": "ShipPostalCode", + "type": "Edm.String" + }, + { + "name": "ShipCountry", + "type": "Edm.String" + } + ], + "refId": "A", + "timeProperty": { + "name": "OrderDate", + "type": "Edm.DateTime" + } + } + ], + "title": "Products table (Northwind-Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "entitySet": { + "entityType": "NorthwindModel.Order", + "name": "Orders" + }, + "properties": [ + { + "name": "OrderID", + "type": "Edm.Int32" + }, + { + "name": "CustomerID", + "type": "Edm.String" + }, + { + "name": "EmployeeID", + "type": "Edm.Int32" + }, + { + "name": "RequiredDate", + "type": "Edm.DateTime" + }, + { + "name": "ShippedDate", + "type": "Edm.DateTime" + }, + { + "name": "ShipVia", + "type": "Edm.Int32" + }, + { + "name": "Freight", + "type": "Edm.Decimal" + }, + { + "name": "ShipName", + "type": "Edm.String" + }, + { + "name": "ShipAddress", + "type": "Edm.String" + }, + { + "name": "ShipCity", + "type": "Edm.String" + }, + { + "name": "ShipRegion", + "type": "Edm.String" + }, + { + "name": "ShipPostalCode", + "type": "Edm.String" + }, + { + "name": "ShipCountry", + "type": "Edm.String" + } + ], + "refId": "A", + "timeProperty": { + "name": "OrderDate", + "type": "Edm.DateTime" + } + } + ], + "title": "Products table (Northwind-Reference-V2)", + "type": "table" + } + ], + "refresh": "", + "revision": 1, + "schemaVersion": 38, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "1991-01-01T00:00:00.000Z", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [], + "time_options": [] + }, + "timezone": "browser", + "title": "OData-Reference-Time", + "uid": "zCA1lZbNx", + "version": 1, + "weekStart": "" +} diff --git a/provisioning/dashboards/reference_dashboard.json b/provisioning/dashboards/reference_dashboard.json new file mode 100644 index 00000000..8eb61b40 --- /dev/null +++ b/provisioning/dashboards/reference_dashboard.json @@ -0,0 +1,478 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B04" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 4, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B04" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "ReleaseDate", + "type": "Edm.DateTimeOffset" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTimeOffset" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ] + } + ], + "title": "Products table (Reference-V4)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B03" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B03" + }, + "entitySet": { + "entityType": "ODataDemo.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ID", + "type": "Edm.Int32" + }, + { + "name": "Name", + "type": "Edm.String" + }, + { + "name": "Description", + "type": "Edm.String" + }, + { + "name": "ReleaseDate", + "type": "Edm.DateTime" + }, + { + "name": "DiscontinuedDate", + "type": "Edm.DateTime" + }, + { + "name": "Rating", + "type": "Edm.Int16" + }, + { + "name": "Price", + "type": "Edm.Double" + } + ], + "refId": "A" + } + ], + "title": "Products table (Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B02" + }, + "entitySet": { + "entityType": "NorthwindModel.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ProductID", + "type": "Edm.Int32" + }, + { + "name": "ProductName", + "type": "Edm.String" + }, + { + "name": "SupplierID", + "type": "Edm.Int32" + }, + { + "name": "CategoryID", + "type": "Edm.Int32" + }, + { + "name": "QuantityPerUnit", + "type": "Edm.String" + }, + { + "name": "UnitPrice", + "type": "Edm.Decimal" + }, + { + "name": "UnitsInStock", + "type": "Edm.Int16" + }, + { + "name": "UnitsOnOrder", + "type": "Edm.Int16" + }, + { + "name": "ReorderLevel", + "type": "Edm.Int16" + }, + { + "name": "Discontinued", + "type": "Edm.Boolean" + } + ], + "refId": "A" + } + ], + "title": "Products table (Northwind-Reference-V3)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 8, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0B01" + }, + "entitySet": { + "entityType": "NorthwindModel.Product", + "name": "Products" + }, + "properties": [ + { + "name": "ProductID", + "type": "Edm.Int32" + }, + { + "name": "ProductName", + "type": "Edm.String" + }, + { + "name": "SupplierID", + "type": "Edm.Int32" + }, + { + "name": "CategoryID", + "type": "Edm.Int32" + }, + { + "name": "QuantityPerUnit", + "type": "Edm.String" + }, + { + "name": "UnitPrice", + "type": "Edm.Decimal" + }, + { + "name": "UnitsInStock", + "type": "Edm.Int16" + }, + { + "name": "UnitsOnOrder", + "type": "Edm.Int16" + }, + { + "name": "ReorderLevel", + "type": "Edm.Int16" + }, + { + "name": "Discontinued", + "type": "Edm.Boolean" + } + ], + "refId": "A" + } + ], + "title": "Products table (Northwind-Reference-V2)", + "type": "table" + } + ], + "refresh": "", + "revision": 1, + "schemaVersion": 38, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-12h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [], + "time_options": [] + }, + "timezone": "browser", + "title": "OData-Reference", + "uid": "yBD7GFEov", + "version": 1, + "weekStart": "" +} diff --git a/provisioning/dashboards/test_dashboard.json b/provisioning/dashboards/test_dashboard.json index cc754dd1..d30881cd 100644 --- a/provisioning/dashboards/test_dashboard.json +++ b/provisioning/dashboards/test_dashboard.json @@ -116,6 +116,121 @@ "title": "Temperatures table", "type": "table" }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A01" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "graph": false, + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A01" + }, + "entitySet": { + "entityType": "TestService.Temperatures", + "name": "Temperatures" + }, + "properties": [ + { + "name": "value1", + "type": "Edm.Double" + }, + { + "name": "value2", + "type": "Edm.Double" + }, + { + "name": "value3", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "time", + "type": "Edm.DateTimeOffset" + } + } + ], + "title": "Temperatures graph", + "type": "timeseries" + }, { "datasource": { "type": "dvelop-odata-datasource", @@ -154,8 +269,8 @@ "gridPos": { "h": 8, "w": 12, - "x": 12, - "y": 0 + "x": 0, + "y": 8 }, "id": 6, "options": { @@ -208,7 +323,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A01" + "uid": "PD461F79B494E0A02" }, "fieldConfig": { "defaults": { @@ -269,10 +384,10 @@ "gridPos": { "h": 8, "w": 12, - "x": 0, + "x": 12, "y": 8 }, - "id": 2, + "id": 8, "options": { "legend": { "calcs": [], @@ -285,14 +400,15 @@ "sort": "none" } }, + "pluginVersion": "10.2.0", "targets": [ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A01" + "uid": "PD461F79B494E0A02" }, "entitySet": { - "entityType": "TestService.Temperatures", + "entityType": "GrafanaMock.Temperature", "name": "Temperatures" }, "properties": [ @@ -316,13 +432,102 @@ } } ], - "title": "Temperatures graph", + "title": "Temperatures graph (mocked)", "type": "timeseries" }, { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A02" + "uid": "PD461F79B494E0A03" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 9, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.2.0", + "targets": [ + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A03" + }, + "entitySet": { + "entityType": "TestService.Temperatures", + "name": "Temperatures" + }, + "properties": [ + { + "name": "value1", + "type": "Edm.Double" + }, + { + "name": "value2", + "type": "Edm.Double" + }, + { + "name": "value3", + "type": "Edm.Double" + } + ], + "refId": "A", + "timeProperty": { + "name": "time", + "type": "Edm.DateTimeOffset" + } + } + ], + "title": "Temperatures table (V2)", + "type": "table" + }, + { + "datasource": { + "type": "dvelop-odata-datasource", + "uid": "PD461F79B494E0A03" }, "fieldConfig": { "defaults": { @@ -384,9 +589,9 @@ "h": 8, "w": 12, "x": 12, - "y": 8 + "y": 16 }, - "id": 8, + "id": 10, "options": { "legend": { "calcs": [], @@ -399,11 +604,12 @@ "sort": "none" } }, + "pluginVersion": "10.2.0", "targets": [ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0A02" + "uid": "PD461F79B494E0A03" }, "entitySet": { "entityType": "GrafanaMock.Temperature", @@ -430,7 +636,7 @@ } } ], - "title": "Temperatures graph (mocked)", + "title": "Temperatures graph (V2)", "type": "timeseries" } ], diff --git a/provisioning/datasources/reference_datasources.yml b/provisioning/datasources/reference_datasources.yml index 7c51dba1..6a1af7d4 100644 --- a/provisioning/datasources/reference_datasources.yml +++ b/provisioning/datasources/reference_datasources.yml @@ -7,6 +7,8 @@ deleteDatasources: orgId: 1 - name: 'Reference-V3' orgId: 1 + - name: 'Reference-V4' + orgId: 1 datasources: - name: 'Northwind-Reference-V2' @@ -33,3 +35,11 @@ datasources: url: https://services.odata.org/V3/OData/OData.svc version: 1 editable: true + - name: 'Reference-V4' + uid: PD461F79B494E0B04 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false + url: https://services.odata.org/V4/OData/OData.svc + version: 1 + editable: true diff --git a/test-server/package.json b/test-server/package.json index 6d925880..ada5e4d3 100644 --- a/test-server/package.json +++ b/test-server/package.json @@ -8,8 +8,8 @@ "author": "d.velop AG", "license": "Apache-2.0", "dependencies": { - "@sap/cds": "^8.8.3", - "@cap-js-community/odata-v2-adapter": "^1.11.11", + "@sap/cds": "^8.9.1", + "@cap-js-community/odata-v2-adapter": "^1.14.3", "@types/express": "^5.0.1", "@types/uuid": "^10.0.0", "express": "^4.21.2", diff --git a/test-server/yarn.lock b/test-server/yarn.lock index c7a3381b..8559be6b 100644 --- a/test-server/yarn.lock +++ b/test-server/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@cap-js-community/odata-v2-adapter@^1.11.11": +"@cap-js-community/odata-v2-adapter@^1.14.3": version "1.14.3" resolved "https://registry.yarnpkg.com/@cap-js-community/odata-v2-adapter/-/odata-v2-adapter-1.14.3.tgz#4e071ba3b09251a98580528fb1371e7377807bfa" integrity sha512-E70/wnMZxZnXnnaL8DTLTun08NpT+iFN+1uJmSNVNIqBtMpNmK3cPvijLUyppE7FnOqyfxWP1URtMCt3YXAyhw== @@ -86,10 +86,10 @@ xmlbuilder "^15.1.1" yaml "^2.2.2" -"@sap/cds@^8.8.3": - version "8.8.3" - resolved "https://registry.yarnpkg.com/@sap/cds/-/cds-8.8.3.tgz#f0059cb3045b237a1004940e1d711188e2ce9ca3" - integrity sha512-4OuQ1LTwFQYveMsPivyIfGN7kWIipB8kcG37ddGCrk0lNSv+dNBPpC1nUeOvCbFh0b0lu+shPpuqjQjCyR3pag== +"@sap/cds@^8.9.1": + version "8.9.1" + resolved "https://registry.yarnpkg.com/@sap/cds/-/cds-8.9.1.tgz#1a13a844dd97b170fa3b4bb10dc304b2052b1e4d" + integrity sha512-+KoY7Bw1Gc3vwK4X3CFCV+IAQO4QT0HRsaW/qgETeOGqjzbuf6KvUqlkDuPE5msdXwfz/EFDB9Vx9jTyTw581Q== dependencies: "@sap/cds-compiler" ">=5.1" "@sap/cds-fiori" "^1" From 8d22587dc81653b41b94f90fe46fa00a8a1bdc6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B6ttgers=20Carsten?= Date: Mon, 7 Apr 2025 09:43:34 +0200 Subject: [PATCH 09/11] Fix test --- pkg/plugin/odata/functions_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/plugin/odata/functions_test.go b/pkg/plugin/odata/functions_test.go index 5d89e42d..85fc7620 100644 --- a/pkg/plugin/odata/functions_test.go +++ b/pkg/plugin/odata/functions_test.go @@ -62,7 +62,7 @@ func TestParseTime(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name+": "+tc.input, func(t *testing.T) { - ts, err := ParseTime(tc.input) + ts, err := parseTime(tc.input) require.NoError(t, err) require.Equal(t, tc.expectedTime.UTC(), ts.UTC()) }) From bfa9565861440eb4c811421056624929505bf3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B6ttgers=20Carsten?= Date: Sun, 1 Mar 2026 11:30:54 +0100 Subject: [PATCH 10/11] Implement version aware filters --- Magefile.go | 18 + pkg/plugin/client.go | 72 ++- pkg/plugin/client_query_test.go | 65 ++- pkg/plugin/integration_test.go | 410 ++++++++++++++++++ .../dashboards/ref_time_dashboard.json | 14 +- .../dashboards/reference_dashboard.json | 8 +- .../datasources/reference_datasources.yml | 20 +- 7 files changed, 579 insertions(+), 28 deletions(-) create mode 100644 pkg/plugin/integration_test.go diff --git a/Magefile.go b/Magefile.go index 4710a8a0..f2e8ed6a 100644 --- a/Magefile.go +++ b/Magefile.go @@ -4,9 +4,27 @@ package main import ( + "os" + + "github.com/magefile/mage/sh" + // mage:import build "github.com/grafana/grafana-plugin-sdk-go/build" ) // Default configures the default target. var Default = build.BuildAll + +func TestIntegration() error { + timeout := "120s" + if t := os.Getenv("TEST_INTEGRATION_TIMEOUT"); t != "" { + timeout = t + } + return sh.RunV("go", "test", + "-tags", "integration", + "-v", + "-timeout", timeout, + "-run", "TestIntegration", + "./pkg/plugin/...", + ) +} diff --git a/pkg/plugin/client.go b/pkg/plugin/client.go index 4c34a71f..295d2f9e 100644 --- a/pkg/plugin/client.go +++ b/pkg/plugin/client.go @@ -7,6 +7,7 @@ import ( "net/url" "path" "strings" + "time" "github.com/d-velop/grafana-odata-datasource/pkg/plugin/odata" "github.com/grafana/grafana-plugin-sdk-go/backend/log" @@ -55,7 +56,7 @@ func (client *ODataClientImpl) GetMetadata(ctx context.Context) (*http.Response, func (client *ODataClientImpl) Get(ctx context.Context, entitySet string, properties []property, filterConditions []filterCondition) (*http.Response, error) { requestUrl, err := buildQueryUrl(client.baseUrl, entitySet, properties, - filterConditions, client.urlSpaceEncoding) + filterConditions, client.urlSpaceEncoding, client.odataVersion) if err != nil { return nil, err } @@ -64,14 +65,14 @@ func (client *ODataClientImpl) Get(ctx context.Context, entitySet string, proper return client.get(ctx, urlString, "application/json") } -func buildQueryUrl(baseUrl string, entitySet string, properties []property, filterConditions []filterCondition, urlSpaceEncoding string) (*url.URL, error) { +func buildQueryUrl(baseUrl string, entitySet string, properties []property, filterConditions []filterCondition, urlSpaceEncoding string, version string) (*url.URL, error) { requestUrl, err := url.Parse(baseUrl) if err != nil { return nil, err } requestUrl.Path = path.Join(requestUrl.Path, entitySet) params, _ := url.ParseQuery(requestUrl.RawQuery) - filterParam := mapFilter(filterConditions) + filterParam := mapFilter(filterConditions, version) if len(filterParam) > 0 { params.Add(odata.Filter, filterParam) } @@ -97,18 +98,71 @@ func mapSelect(properties []property) string { return strings.Join(result[:], ",") } -func mapFilter(filterConditions []filterCondition) string { +func mapFilter(filterConditions []filterCondition, version string) string { + isV2V3 := version == "V2" || version == "V3" var filter = "" for index, element := range filterConditions { - if element.Property.Type == odata.EdmString { - filter += fmt.Sprintf("%s %s '%s'", element.Property.Name, element.Operator, element.Value) - } else { - filter += fmt.Sprintf("%s %s %s", element.Property.Name, element.Operator, element.Value) + name, op, val := element.Property.Name, element.Operator, element.Value + switch element.Property.Type { + case odata.EdmString: + filter += fmt.Sprintf("%s %s '%s'", name, op, val) + case odata.EdmDateTime: + if isV2V3 { + // V2/V3: datetime'yyyy-mm-ddThh:mm:ss' without timezone suffix + filter += fmt.Sprintf("%s %s datetime'%s'", name, op, stripTimezoneForV2(val)) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmDateTimeOffset: + if isV2V3 { + filter += fmt.Sprintf("%s %s datetimeoffset'%s'", name, op, val) + } else { + // V4: plain ISO 8601 value, no prefix + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmInt64: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sL", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmDecimal: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sM", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmSingle: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sf", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmDouble: + if isV2V3 { + filter += fmt.Sprintf("%s %s %sd", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + case odata.EdmGuid: + if isV2V3 { + filter += fmt.Sprintf("%s %s guid'%s'", name, op, val) + } else { + filter += fmt.Sprintf("%s %s %s", name, op, val) + } + default: + filter += fmt.Sprintf("%s %s %s", name, op, val) } if index < (len(filterConditions) - 1) { filter += " and " } } - return filter } + +func stripTimezoneForV2(value string) string { + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t.UTC().Format("2006-01-02T15:04:05") + } + return value +} diff --git a/pkg/plugin/client_query_test.go b/pkg/plugin/client_query_test.go index c3517d53..0bc6015f 100644 --- a/pkg/plugin/client_query_test.go +++ b/pkg/plugin/client_query_test.go @@ -14,11 +14,13 @@ import ( func TestMapFilter(t *testing.T) { tables := []struct { name string + version string filterConditions []filterCondition expected string }{ + // --- V4 / default behavior --- { - name: "Time filter only", + name: "V4: DateTimeOffset filter", filterConditions: someFilterConditions( withFilterCondition(timeProp, "ge", aOneDayTimeRange().From.Format(time.RFC3339)), withFilterCondition(timeProp, "le", aOneDayTimeRange().To.Format(time.RFC3339)), @@ -26,7 +28,7 @@ func TestMapFilter(t *testing.T) { expected: "time ge 2022-04-21T12:30:50Z and time le 2022-04-21T12:30:50Z and int32 eq 5", }, { - name: "Time filter and int and string filter", + name: "V4: DateTimeOffset and string filter", filterConditions: someFilterConditions( withFilterCondition(timeProp, "ge", aOneDayTimeRange().From.Format(time.RFC3339)), withFilterCondition(timeProp, "le", aOneDayTimeRange().To.Format(time.RFC3339)), @@ -35,7 +37,7 @@ func TestMapFilter(t *testing.T) { expected: "time ge 2022-04-21T12:30:50Z and time le 2022-04-21T12:30:50Z and int32 eq 5 and string eq 'Hello'", }, { - name: "Time filter and string filter", + name: "V4: DateTimeOffset and empty string filter", filterConditions: someFilterConditions( withFilterCondition(timeProp, "ge", aOneDayTimeRange().From.Format(time.RFC3339)), withFilterCondition(timeProp, "le", aOneDayTimeRange().To.Format(time.RFC3339)), @@ -43,16 +45,67 @@ func TestMapFilter(t *testing.T) { expected: "time ge 2022-04-21T12:30:50Z and time le 2022-04-21T12:30:50Z and string eq ''", }, { - name: "String filter only", + name: "V4: String filter only", filterConditions: someFilterConditions(withFilterCondition(stringProp, "eq", "")), expected: "string eq ''", }, + // --- V2 behavior --- + { + name: "V2: DateTimeOffset wraps with datetimeoffset prefix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(timeProp, "ge", "2022-04-21T12:30:50Z"), + withFilterCondition(timeProp, "le", "2022-04-21T12:30:50Z")), + expected: "time ge datetimeoffset'2022-04-21T12:30:50Z' and time le datetimeoffset'2022-04-21T12:30:50Z'", + }, + { + name: "V2: DateTime wraps with datetime prefix and strips timezone", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "ts"; p.Type = odata.EdmDateTime }, "ge", "2022-04-21T12:30:50Z")), + expected: "ts ge datetime'2022-04-21T12:30:50'", + }, + { + name: "V2: Int64 gets L suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "count"; p.Type = odata.EdmInt64 }, "eq", "42")), + expected: "count eq 42L", + }, + { + name: "V2: Decimal gets M suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "amount"; p.Type = odata.EdmDecimal }, "eq", "12.34")), + expected: "amount eq 12.34M", + }, + { + name: "V2: Single gets f suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "discount"; p.Type = odata.EdmSingle }, "gt", "0")), + expected: "discount gt 0f", + }, + { + name: "V2: Double gets d suffix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "ratio"; p.Type = odata.EdmDouble }, "gt", "1.5")), + expected: "ratio gt 1.5d", + }, + { + name: "V2: Guid gets guid prefix", + version: "V2", + filterConditions: someFilterConditions( + withFilterCondition(func(p *property) { p.Name = "id"; p.Type = odata.EdmGuid }, "eq", "12345678-1234-1234-1234-123456789abc")), + expected: "id eq guid'12345678-1234-1234-1234-123456789abc'", + }, } for _, table := range tables { t.Run(table.name, func(t *testing.T) { // Act - var filterString = mapFilter(table.filterConditions) + var filterString = mapFilter(table.filterConditions, table.version) // Assert assert.Equal(t, table.expected, filterString) @@ -87,7 +140,7 @@ func TestBuildQueryUrl(t *testing.T) { for _, table := range tables { t.Run(table.name, func(t *testing.T) { // Act - var builtUrl, err = buildQueryUrl(table.baseUrl, table.entitySet, table.properties, table.filterConditions, "+") + var builtUrl, err = buildQueryUrl(table.baseUrl, table.entitySet, table.properties, table.filterConditions, "+", "") // Assert assert.NoError(t, err) diff --git a/pkg/plugin/integration_test.go b/pkg/plugin/integration_test.go new file mode 100644 index 00000000..a5ffac38 --- /dev/null +++ b/pkg/plugin/integration_test.go @@ -0,0 +1,410 @@ +//go:build integration + +package plugin + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/d-velop/grafana-odata-datasource/pkg/plugin/odata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type filterTestCase struct { + name string + entitySet string + properties []property + filterConditions []filterCondition + expectedResults int // expectation: exact number of entries the server must return +} + +type referenceSystem struct { + name string + baseURL string + version string // "V2", "V3", "V4" + filterTests []filterTestCase +} + +var northwindFilterTests = []filterTestCase{ + { + name: "date range on Orders.OrderDate", + entitySet: "Orders", + properties: []property{ + {Name: "OrderID"}, + {Name: "CustomerID"}, + {Name: "OrderDate"}, + {Name: "Freight"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "OrderDate"}, Operator: "ge", Value: "1996-07-01T00:00:00Z"}, + {Property: property{Name: "OrderDate"}, Operator: "le", Value: "1996-07-31T23:59:59Z"}, + }, + expectedResults: 22, + }, + { + name: "decimal range on Products.UnitPrice", + entitySet: "Products", + properties: []property{ + {Name: "ProductID"}, + {Name: "ProductName"}, + {Name: "UnitPrice"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "UnitPrice"}, Operator: "ge", Value: "10"}, + {Property: property{Name: "UnitPrice"}, Operator: "le", Value: "50"}, + }, + expectedResults: 20, + }, + { + name: "single equality on Order_Details.Discount", + entitySet: "Order_Details", + properties: []property{ + {Name: "OrderID"}, + {Name: "ProductID"}, + {Name: "Discount"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Discount"}, Operator: "eq", Value: "0.03"}, + }, + expectedResults: 3, + }, +} + +var parliFilterTests = []filterTestCase{ + { + name: "int64 range on Meeting.ID", + entitySet: "Meeting", + properties: []property{ + {Name: "ID"}, + {Name: "Language"}, + {Name: "MeetingNumber"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ID"}, Operator: "ge", Value: "1000"}, + {Property: property{Name: "ID"}, Operator: "le", Value: "1010"}, + }, + expectedResults: 55, + }, +} + +var usgsFilterTests = []filterTestCase{ + { + name: "int64 range on Sites.SiteID", + entitySet: "Sites", + properties: []property{ + {Name: "SiteID"}, + {Name: "SiteName"}, + {Name: "SiteTypeCode"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "SiteID"}, Operator: "ge", Value: "1"}, + {Property: property{Name: "SiteID"}, Operator: "le", Value: "100"}, + }, + expectedResults: 89, + }, +} + +var odataSvcFilterTests = []filterTestCase{ + { + name: "date range on Products.ReleaseDate", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "ReleaseDate"}, + {Name: "Price"}, + {Name: "Rating"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ReleaseDate"}, Operator: "ge", Value: "2000-01-01T00:00:00Z"}, + {Property: property{Name: "ReleaseDate"}, Operator: "le", Value: "2006-12-31T23:59:59Z"}, + }, + expectedResults: 6, + }, + { + name: "double range on Products.Price", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "Price"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Price"}, Operator: "ge", Value: "18"}, + {Property: property{Name: "Price"}, Operator: "le", Value: "25"}, + }, + expectedResults: 5, + }, + { + name: "int16 filter on Products.Rating", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "Rating"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Rating"}, Operator: "ge", Value: "4"}, + }, + expectedResults: 3, + }, + { + name: "guid equality on Advertisements.ID", + entitySet: "Advertisements", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ID"}, Operator: "eq", Value: "f89dee73-af9f-4cd4-b330-db93c25ff3c7"}, + }, + expectedResults: 1, + }, +} + +var referenceSystems = []referenceSystem{ + { + name: "Northwind V2 (services.odata.org)", + baseURL: "https://services.odata.org/V2/Northwind/Northwind.svc", + version: "V2", + filterTests: northwindFilterTests, + }, + { + name: "Northwind V3 (services.odata.org)", + baseURL: "https://services.odata.org/V3/Northwind/Northwind.svc", + version: "V3", + filterTests: northwindFilterTests, + }, + { + name: "Northwind V4 (services.odata.org)", + baseURL: "https://services.odata.org/V4/Northwind/Northwind.svc", + version: "V4", + filterTests: northwindFilterTests, + }, + { + name: "OData V3 demo (services.odata.org)", + baseURL: "https://services.odata.org/V3/OData/OData.svc", + version: "V3", + filterTests: odataSvcFilterTests, + }, + { + name: "OData V4 demo (services.odata.org)", + baseURL: "https://services.odata.org/V4/OData/OData.svc", + version: "V4", + filterTests: odataSvcFilterTests, + }, + { + name: "Swiss Parliament V2 (ws.parlament.ch)", + baseURL: "https://ws.parlament.ch/odata.svc", + version: "V2", + filterTests: parliFilterTests, + }, + { + name: "USGS Water Data V4 (waterdata.usgs.gov)", + baseURL: "https://dashboard.waterdata.usgs.gov/service/cwis/1.0/odata", + version: "V4", + filterTests: usgsFilterTests, + }, +} + +type propertyTypeMap map[string]map[string]string + +func fetchPropertyTypes(t *testing.T, c *ODataClientImpl) propertyTypeMap { + t.Helper() + resp, err := c.GetMetadata(context.Background()) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var edmx odata.Edmx + require.NoError(t, xml.Unmarshal(body, &edmx)) + + etProps := map[string]map[string]string{} + for _, ds := range edmx.DataServices { + for _, schema := range ds.Schemas { + for _, et := range schema.EntityTypes { + props := map[string]string{} + for _, p := range et.Properties { + props[p.Name] = p.Type + } + etProps[schema.Namespace+"."+et.Name] = props + } + } + } + + result := propertyTypeMap{} + for _, ds := range edmx.DataServices { + for _, schema := range ds.Schemas { + for _, ec := range schema.EntityContainers { + for _, es := range ec.EntitySet { + if props, ok := etProps[es.EntityType]; ok { + result[es.Name] = props + } + } + } + } + } + return result +} + +func withResolvedTypes(types propertyTypeMap, entitySet string, properties []property, filterConditions []filterCondition) ([]property, []filterCondition) { + propTypes := types[entitySet] + + resolvedProps := make([]property, len(properties)) + for i, p := range properties { + p.Type = propTypes[p.Name] + resolvedProps[i] = p + } + + resolvedConds := make([]filterCondition, len(filterConditions)) + for i, c := range filterConditions { + c.Property.Type = propTypes[c.Property.Name] + resolvedConds[i] = c + } + + return resolvedProps, resolvedConds +} + +func fetchUnfilteredCount(t *testing.T, rs referenceSystem, entitySet string) int { + t.Helper() + c := newIntegrationClient(rs) + + if rs.version == "V4" { + rawURL := fmt.Sprintf("%s/%s/$count", c.baseUrl, entitySet) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + require.NoError(t, err) + req.Header.Set("Accept", "text/plain") + c.addVersionHeaders(req) + resp, err := c.httpClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + count, err := strconv.Atoi(strings.Trim(string(body), "\ufeff\r\n\t ")) + require.NoError(t, err) + return count + } + + rawURL := fmt.Sprintf("%s/%s?$inlinecount=allpages&$top=1", c.baseUrl, entitySet) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + require.NoError(t, err) + req.Header.Set("Accept", "application/json") + c.addVersionHeaders(req) + resp, err := c.httpClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var v2Result struct { + D struct { + Count string `json:"__count"` + } `json:"d"` + } + if json.Unmarshal(body, &v2Result) == nil && v2Result.D.Count != "" { + count, err := strconv.Atoi(v2Result.D.Count) + require.NoError(t, err) + return count + } + + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &raw)) + var countStr string + require.NoError(t, json.Unmarshal(raw["odata.count"], &countStr)) + count, err := strconv.Atoi(countStr) + require.NoError(t, err) + return count +} + +func newIntegrationClient(rs referenceSystem) *ODataClientImpl { + return &ODataClientImpl{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseUrl: rs.baseURL, + odataVersion: rs.version, + } +} + +func TestIntegration_ServiceRoot(t *testing.T) { + for _, rs := range referenceSystems { + rs := rs + t.Run(rs.name, func(t *testing.T) { + client := newIntegrationClient(rs) + resp, err := client.GetServiceRoot(context.Background()) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + } +} + +func TestIntegration_Metadata(t *testing.T) { + for _, rs := range referenceSystems { + rs := rs + t.Run(rs.name, func(t *testing.T) { + client := newIntegrationClient(rs) + resp, err := client.GetMetadata(context.Background()) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + var edmx odata.Edmx + err = xml.Unmarshal(body, &edmx) + require.NoError(t, err, "EDMX must be parseable") + + var totalEntityTypes int + for _, ds := range edmx.DataServices { + for _, schema := range ds.Schemas { + totalEntityTypes += len(schema.EntityTypes) + } + } + assert.Greater(t, totalEntityTypes, 0, "expected at least one entity type in metadata") + }) + } +} + +func TestIntegration_QueryWithFilter(t *testing.T) { + for _, rs := range referenceSystems { + rs := rs + t.Run(rs.name, func(t *testing.T) { + client := newIntegrationClient(rs) + types := fetchPropertyTypes(t, client) + + for _, tc := range rs.filterTests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + props, conds := withResolvedTypes(types, tc.entitySet, tc.properties, tc.filterConditions) + + resp, err := client.Get(context.Background(), tc.entitySet, props, conds) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, + "server rejected filter — check filter syntax for version %s", rs.version) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer resp.Body.Close() + + entries, err := odata.MapToResponse(body) + require.NoError(t, err) + assert.Equal(t, tc.expectedResults, len(entries)) + + unfilteredTotal := fetchUnfilteredCount(t, rs, tc.entitySet) + assert.Greater(t, unfilteredTotal, len(entries), + "unfiltered total (%d) must exceed filtered result count (%d)", + unfilteredTotal, len(entries)) + }) + } + }) + } +} diff --git a/provisioning/dashboards/ref_time_dashboard.json b/provisioning/dashboards/ref_time_dashboard.json index 96191b31..b4b9fa8c 100644 --- a/provisioning/dashboards/ref_time_dashboard.json +++ b/provisioning/dashboards/ref_time_dashboard.json @@ -30,7 +30,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B04" + "uid": "PD461F79B494E0B12" }, "fieldConfig": { "defaults": { @@ -86,7 +86,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B04" + "uid": "PD461F79B494E0B12" }, "entitySet": { "entityType": "ODataDemo.Product", @@ -131,7 +131,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B03" + "uid": "PD461F79B494E0B11" }, "fieldConfig": { "defaults": { @@ -187,7 +187,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B03" + "uid": "PD461F79B494E0B11" }, "entitySet": { "entityType": "ODataDemo.Product", @@ -226,7 +226,7 @@ } } ], - "title": "Orders table (Reference-V3)", + "title": "Products table (Reference-V3)", "type": "table" }, { @@ -355,7 +355,7 @@ } } ], - "title": "Products table (Northwind-Reference-V3)", + "title": "Orders table (Northwind-Reference-V3)", "type": "table" }, { @@ -484,7 +484,7 @@ } } ], - "title": "Products table (Northwind-Reference-V2)", + "title": "Orders table (Northwind-Reference-V2)", "type": "table" } ], diff --git a/provisioning/dashboards/reference_dashboard.json b/provisioning/dashboards/reference_dashboard.json index 8eb61b40..b495e67a 100644 --- a/provisioning/dashboards/reference_dashboard.json +++ b/provisioning/dashboards/reference_dashboard.json @@ -30,7 +30,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B04" + "uid": "PD461F79B494E0B12" }, "fieldConfig": { "defaults": { @@ -86,7 +86,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B04" + "uid": "PD461F79B494E0B12" }, "entitySet": { "entityType": "ODataDemo.Product", @@ -130,7 +130,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B03" + "uid": "PD461F79B494E0B11" }, "fieldConfig": { "defaults": { @@ -186,7 +186,7 @@ { "datasource": { "type": "dvelop-odata-datasource", - "uid": "PD461F79B494E0B03" + "uid": "PD461F79B494E0B11" }, "entitySet": { "entityType": "ODataDemo.Product", diff --git a/provisioning/datasources/reference_datasources.yml b/provisioning/datasources/reference_datasources.yml index 6a1af7d4..87fb6c96 100644 --- a/provisioning/datasources/reference_datasources.yml +++ b/provisioning/datasources/reference_datasources.yml @@ -5,6 +5,8 @@ deleteDatasources: orgId: 1 - name: 'Northwind-Reference-V3' orgId: 1 + - name: 'Northwind-Reference-V4' + orgId: 1 - name: 'Reference-V3' orgId: 1 - name: 'Reference-V4' @@ -19,6 +21,8 @@ datasources: url: https://services.odata.org/V2/Northwind/Northwind.svc version: 1 editable: true + jsonData: + odataVersion: 'V2' - name: 'Northwind-Reference-V3' uid: PD461F79B494E0B02 type: dvelop-odata-datasource @@ -27,16 +31,28 @@ datasources: url: https://services.odata.org/V3/Northwind/Northwind.svc version: 1 editable: true - - name: 'Reference-V3' + jsonData: + odataVersion: 'V3' + - name: 'Northwind-Reference-V4' uid: PD461F79B494E0B03 type: dvelop-odata-datasource orgId: 1 isDefault: false + url: https://services.odata.org/V4/Northwind/Northwind.svc + version: 1 + editable: true + - name: 'Reference-V3' + uid: PD461F79B494E0B11 + type: dvelop-odata-datasource + orgId: 1 + isDefault: false url: https://services.odata.org/V3/OData/OData.svc version: 1 editable: true + jsonData: + odataVersion: 'V3' - name: 'Reference-V4' - uid: PD461F79B494E0B04 + uid: PD461F79B494E0B12 type: dvelop-odata-datasource orgId: 1 isDefault: false From 0e3e0472dc463132814b9f6808596730d9e98132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B6ttgers=20Carsten?= Date: Mon, 2 Mar 2026 14:50:20 +0100 Subject: [PATCH 11/11] Additional test coverage, polishing --- DEVELOPING.md | 41 +++++ pkg/plugin/client.go | 2 - pkg/plugin/integration_local_test.go | 124 +++++++++++++++ pkg/plugin/integration_public_test.go | 187 ++++++++++++++++++++++ pkg/plugin/integration_test.go | 221 ++++---------------------- test-server/db/data-model.cds | 31 +++- test-server/generateTestdata.ts | 3 +- test-server/mock/Testdata.ts | 39 ++++- test-server/mock/model/MockModel.ts | 19 ++- test-server/srv/test-service.cds | 1 + 10 files changed, 461 insertions(+), 207 deletions(-) create mode 100644 pkg/plugin/integration_local_test.go create mode 100644 pkg/plugin/integration_public_test.go diff --git a/DEVELOPING.md b/DEVELOPING.md index 8cdd585d..0b6f64fb 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -85,6 +85,47 @@ Run all backend test by executing the following command: mage test ``` +### Integration Tests + +Integration tests run against real OData endpoints to verify that filter expressions, metadata parsing, and response +handling work correctly across OData versions (V2, V3, V4). + +| File | Description | +|-----------------------------------------|-------------------------------------------------------------------------| +| `pkg/plugin/integration_test.go` | Shared infrastructure: types, helpers, test runner | +| `pkg/plugin/integration_public_test.go` | Public internet services (Northwind, OData.svc, Swiss Parliament, USGS) | +| `pkg/plugin/integration_local_test.go` | Local test server (V4 and V2 via OData V2 adapter) | + +#### Run all integration tests + +Requires network access for the public services. + +```bash +mage testIntegration +``` + +Or directly via `go test`: + +```bash +go test -tags integration -v -timeout 120s -run TestIntegration ./pkg/plugin/... +``` + +#### Run only local tests + +Local tests exercise the test server running at `localhost:4004`. Start it first: + +```bash +cd test-server && pnpm start +``` + +Then run only the local integration tests: + +```bash +go test -tags integration -v -timeout 60s -run "TestIntegration/Local" ./pkg/plugin/... +``` + +If the server is not running, the local tests are skipped automatically with a hint on how to start it. + ### Coverage To evaluate the backend test coverage execute the following command: diff --git a/pkg/plugin/client.go b/pkg/plugin/client.go index 295d2f9e..e9294005 100644 --- a/pkg/plugin/client.go +++ b/pkg/plugin/client.go @@ -108,7 +108,6 @@ func mapFilter(filterConditions []filterCondition, version string) string { filter += fmt.Sprintf("%s %s '%s'", name, op, val) case odata.EdmDateTime: if isV2V3 { - // V2/V3: datetime'yyyy-mm-ddThh:mm:ss' without timezone suffix filter += fmt.Sprintf("%s %s datetime'%s'", name, op, stripTimezoneForV2(val)) } else { filter += fmt.Sprintf("%s %s %s", name, op, val) @@ -117,7 +116,6 @@ func mapFilter(filterConditions []filterCondition, version string) string { if isV2V3 { filter += fmt.Sprintf("%s %s datetimeoffset'%s'", name, op, val) } else { - // V4: plain ISO 8601 value, no prefix filter += fmt.Sprintf("%s %s %s", name, op, val) } case odata.EdmInt64: diff --git a/pkg/plugin/integration_local_test.go b/pkg/plugin/integration_local_test.go new file mode 100644 index 00000000..3b2bfb93 --- /dev/null +++ b/pkg/plugin/integration_local_test.go @@ -0,0 +1,124 @@ +//go:build integration + +package plugin + +var testPrimitivesFilterTests = []filterTestCase{ + { + name: "boolean equality", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "boolean"}, + {Name: "int32"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "boolean"}, Operator: "eq", Value: "true"}, + }, + expectedResults: 50, + }, + { + name: "int32 range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "int32"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "int32"}, Operator: "ge", Value: "50"}, + }, + expectedResults: 50, + }, + { + name: "int64 range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "int64"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "int64"}, Operator: "ge", Value: "50000000000"}, + }, + expectedResults: 50, + }, + { + name: "int16 range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "int16"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "int16"}, Operator: "ge", Value: "50"}, + }, + expectedResults: 50, + }, + { + name: "decimal range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "decimal"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "decimal"}, Operator: "ge", Value: "25"}, + }, + expectedResults: 50, + }, + { + name: "double range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "double"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "double"}, Operator: "gt", Value: "0"}, + }, + expectedResults: 50, + }, + { + name: "string equality", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "string"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "string"}, Operator: "eq", Value: "item-050"}, + }, + expectedResults: 1, + }, + { + name: "dateTimeOffset range", + entitySet: "TestPrimitives", + properties: []property{ + {Name: "guid"}, + {Name: "dateTimeOffset"}, + {Name: "date"}, + {Name: "int16"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "dateTimeOffset"}, Operator: "ge", Value: "2024-01-01T00:30:00Z"}, + }, + expectedResults: 70, + }, +} + +func init() { + referenceSystems = append(referenceSystems, + referenceSystem{ + name: "Local test server V4 (localhost:4004)", + baseURL: "http://localhost:4004/odata/v4/test", + version: "V4", + requiresLocalServer: true, + filterTests: testPrimitivesFilterTests, + }, + referenceSystem{ + name: "Local test server V2 (localhost:4004)", + baseURL: "http://localhost:4004/odata/v2/test", + version: "V2", + requiresLocalServer: true, + filterTests: testPrimitivesFilterTests, + }, + ) +} diff --git a/pkg/plugin/integration_public_test.go b/pkg/plugin/integration_public_test.go new file mode 100644 index 00000000..b7fc30f7 --- /dev/null +++ b/pkg/plugin/integration_public_test.go @@ -0,0 +1,187 @@ +//go:build integration + +package plugin + +var northwindFilterTests = []filterTestCase{ + { + name: "date range on Orders.OrderDate", + entitySet: "Orders", + properties: []property{ + {Name: "OrderID"}, + {Name: "CustomerID"}, + {Name: "OrderDate"}, + {Name: "Freight"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "OrderDate"}, Operator: "ge", Value: "1996-07-01T00:00:00Z"}, + {Property: property{Name: "OrderDate"}, Operator: "le", Value: "1996-07-31T23:59:59Z"}, + }, + expectedResults: 22, + }, + { + name: "decimal range on Products.UnitPrice", + entitySet: "Products", + properties: []property{ + {Name: "ProductID"}, + {Name: "ProductName"}, + {Name: "UnitPrice"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "UnitPrice"}, Operator: "ge", Value: "10"}, + {Property: property{Name: "UnitPrice"}, Operator: "le", Value: "50"}, + }, + expectedResults: 20, + }, + { + name: "single equality on Order_Details.Discount", + entitySet: "Order_Details", + properties: []property{ + {Name: "OrderID"}, + {Name: "ProductID"}, + {Name: "Discount"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Discount"}, Operator: "eq", Value: "0.03"}, + }, + expectedResults: 3, + }, +} + +var parliFilterTests = []filterTestCase{ + { + name: "int64 range on Meeting.ID", + entitySet: "Meeting", + properties: []property{ + {Name: "ID"}, + {Name: "Language"}, + {Name: "MeetingNumber"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ID"}, Operator: "ge", Value: "1000"}, + {Property: property{Name: "ID"}, Operator: "le", Value: "1010"}, + }, + expectedResults: 55, + }, +} + +var usgsFilterTests = []filterTestCase{ + { + name: "int64 range on Sites.SiteID", + entitySet: "Sites", + properties: []property{ + {Name: "SiteID"}, + {Name: "SiteName"}, + {Name: "SiteTypeCode"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "SiteID"}, Operator: "ge", Value: "1"}, + {Property: property{Name: "SiteID"}, Operator: "le", Value: "100"}, + }, + expectedResults: 89, + }, +} + +var odataSvcFilterTests = []filterTestCase{ + { + name: "date range on Products.ReleaseDate", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "ReleaseDate"}, + {Name: "Price"}, + {Name: "Rating"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ReleaseDate"}, Operator: "ge", Value: "2000-01-01T00:00:00Z"}, + {Property: property{Name: "ReleaseDate"}, Operator: "le", Value: "2006-12-31T23:59:59Z"}, + }, + expectedResults: 6, + }, + { + name: "double range on Products.Price", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "Price"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Price"}, Operator: "ge", Value: "18"}, + {Property: property{Name: "Price"}, Operator: "le", Value: "25"}, + }, + expectedResults: 5, + }, + { + name: "int16 filter on Products.Rating", + entitySet: "Products", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + {Name: "Rating"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "Rating"}, Operator: "ge", Value: "4"}, + }, + expectedResults: 3, + }, + { + name: "guid equality on Advertisements.ID", + entitySet: "Advertisements", + properties: []property{ + {Name: "ID"}, + {Name: "Name"}, + }, + filterConditions: []filterCondition{ + {Property: property{Name: "ID"}, Operator: "eq", Value: "f89dee73-af9f-4cd4-b330-db93c25ff3c7"}, + }, + expectedResults: 1, + }, +} + +func init() { + referenceSystems = append(referenceSystems, + referenceSystem{ + name: "Northwind V2 (services.odata.org)", + baseURL: "https://services.odata.org/V2/Northwind/Northwind.svc", + version: "V2", + filterTests: northwindFilterTests, + }, + referenceSystem{ + name: "Northwind V3 (services.odata.org)", + baseURL: "https://services.odata.org/V3/Northwind/Northwind.svc", + version: "V3", + filterTests: northwindFilterTests, + }, + referenceSystem{ + name: "Northwind V4 (services.odata.org)", + baseURL: "https://services.odata.org/V4/Northwind/Northwind.svc", + version: "V4", + filterTests: northwindFilterTests, + }, + referenceSystem{ + name: "OData V3 demo (services.odata.org)", + baseURL: "https://services.odata.org/V3/OData/OData.svc", + version: "V3", + filterTests: odataSvcFilterTests, + }, + referenceSystem{ + name: "OData V4 demo (services.odata.org)", + baseURL: "https://services.odata.org/V4/OData/OData.svc", + version: "V4", + filterTests: odataSvcFilterTests, + }, + referenceSystem{ + name: "Swiss Parliament V2 (ws.parlament.ch)", + baseURL: "https://ws.parlament.ch/odata.svc", + version: "V2", + filterTests: parliFilterTests, + }, + referenceSystem{ + name: "USGS Water Data V4 (waterdata.usgs.gov)", + baseURL: "https://dashboard.waterdata.usgs.gov/service/cwis/1.0/odata", + version: "V4", + filterTests: usgsFilterTests, + }, + ) +} diff --git a/pkg/plugin/integration_test.go b/pkg/plugin/integration_test.go index a5ffac38..7772e27e 100644 --- a/pkg/plugin/integration_test.go +++ b/pkg/plugin/integration_test.go @@ -8,6 +8,7 @@ import ( "encoding/xml" "fmt" "io" + "net" "net/http" "strconv" "strings" @@ -24,197 +25,18 @@ type filterTestCase struct { entitySet string properties []property filterConditions []filterCondition - expectedResults int // expectation: exact number of entries the server must return + expectedResults int // exact number of entries the server must return } type referenceSystem struct { - name string - baseURL string - version string // "V2", "V3", "V4" - filterTests []filterTestCase + name string + baseURL string + version string // "V2", "V3", "V4" + requiresLocalServer bool // if true: skip when localhost:4004 is not reachable + filterTests []filterTestCase } -var northwindFilterTests = []filterTestCase{ - { - name: "date range on Orders.OrderDate", - entitySet: "Orders", - properties: []property{ - {Name: "OrderID"}, - {Name: "CustomerID"}, - {Name: "OrderDate"}, - {Name: "Freight"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "OrderDate"}, Operator: "ge", Value: "1996-07-01T00:00:00Z"}, - {Property: property{Name: "OrderDate"}, Operator: "le", Value: "1996-07-31T23:59:59Z"}, - }, - expectedResults: 22, - }, - { - name: "decimal range on Products.UnitPrice", - entitySet: "Products", - properties: []property{ - {Name: "ProductID"}, - {Name: "ProductName"}, - {Name: "UnitPrice"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "UnitPrice"}, Operator: "ge", Value: "10"}, - {Property: property{Name: "UnitPrice"}, Operator: "le", Value: "50"}, - }, - expectedResults: 20, - }, - { - name: "single equality on Order_Details.Discount", - entitySet: "Order_Details", - properties: []property{ - {Name: "OrderID"}, - {Name: "ProductID"}, - {Name: "Discount"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "Discount"}, Operator: "eq", Value: "0.03"}, - }, - expectedResults: 3, - }, -} - -var parliFilterTests = []filterTestCase{ - { - name: "int64 range on Meeting.ID", - entitySet: "Meeting", - properties: []property{ - {Name: "ID"}, - {Name: "Language"}, - {Name: "MeetingNumber"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "ID"}, Operator: "ge", Value: "1000"}, - {Property: property{Name: "ID"}, Operator: "le", Value: "1010"}, - }, - expectedResults: 55, - }, -} - -var usgsFilterTests = []filterTestCase{ - { - name: "int64 range on Sites.SiteID", - entitySet: "Sites", - properties: []property{ - {Name: "SiteID"}, - {Name: "SiteName"}, - {Name: "SiteTypeCode"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "SiteID"}, Operator: "ge", Value: "1"}, - {Property: property{Name: "SiteID"}, Operator: "le", Value: "100"}, - }, - expectedResults: 89, - }, -} - -var odataSvcFilterTests = []filterTestCase{ - { - name: "date range on Products.ReleaseDate", - entitySet: "Products", - properties: []property{ - {Name: "ID"}, - {Name: "Name"}, - {Name: "ReleaseDate"}, - {Name: "Price"}, - {Name: "Rating"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "ReleaseDate"}, Operator: "ge", Value: "2000-01-01T00:00:00Z"}, - {Property: property{Name: "ReleaseDate"}, Operator: "le", Value: "2006-12-31T23:59:59Z"}, - }, - expectedResults: 6, - }, - { - name: "double range on Products.Price", - entitySet: "Products", - properties: []property{ - {Name: "ID"}, - {Name: "Name"}, - {Name: "Price"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "Price"}, Operator: "ge", Value: "18"}, - {Property: property{Name: "Price"}, Operator: "le", Value: "25"}, - }, - expectedResults: 5, - }, - { - name: "int16 filter on Products.Rating", - entitySet: "Products", - properties: []property{ - {Name: "ID"}, - {Name: "Name"}, - {Name: "Rating"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "Rating"}, Operator: "ge", Value: "4"}, - }, - expectedResults: 3, - }, - { - name: "guid equality on Advertisements.ID", - entitySet: "Advertisements", - properties: []property{ - {Name: "ID"}, - {Name: "Name"}, - }, - filterConditions: []filterCondition{ - {Property: property{Name: "ID"}, Operator: "eq", Value: "f89dee73-af9f-4cd4-b330-db93c25ff3c7"}, - }, - expectedResults: 1, - }, -} - -var referenceSystems = []referenceSystem{ - { - name: "Northwind V2 (services.odata.org)", - baseURL: "https://services.odata.org/V2/Northwind/Northwind.svc", - version: "V2", - filterTests: northwindFilterTests, - }, - { - name: "Northwind V3 (services.odata.org)", - baseURL: "https://services.odata.org/V3/Northwind/Northwind.svc", - version: "V3", - filterTests: northwindFilterTests, - }, - { - name: "Northwind V4 (services.odata.org)", - baseURL: "https://services.odata.org/V4/Northwind/Northwind.svc", - version: "V4", - filterTests: northwindFilterTests, - }, - { - name: "OData V3 demo (services.odata.org)", - baseURL: "https://services.odata.org/V3/OData/OData.svc", - version: "V3", - filterTests: odataSvcFilterTests, - }, - { - name: "OData V4 demo (services.odata.org)", - baseURL: "https://services.odata.org/V4/OData/OData.svc", - version: "V4", - filterTests: odataSvcFilterTests, - }, - { - name: "Swiss Parliament V2 (ws.parlament.ch)", - baseURL: "https://ws.parlament.ch/odata.svc", - version: "V2", - filterTests: parliFilterTests, - }, - { - name: "USGS Water Data V4 (waterdata.usgs.gov)", - baseURL: "https://dashboard.waterdata.usgs.gov/service/cwis/1.0/odata", - version: "V4", - filterTests: usgsFilterTests, - }, -} +var referenceSystems []referenceSystem type propertyTypeMap map[string]map[string]string @@ -284,7 +106,6 @@ func fetchUnfilteredCount(t *testing.T, rs referenceSystem, entitySet string) in req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) require.NoError(t, err) req.Header.Set("Accept", "text/plain") - c.addVersionHeaders(req) resp, err := c.httpClient.Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -299,7 +120,6 @@ func fetchUnfilteredCount(t *testing.T, rs referenceSystem, entitySet string) in req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) require.NoError(t, err) req.Header.Set("Accept", "application/json") - c.addVersionHeaders(req) resp, err := c.httpClient.Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -328,16 +148,29 @@ func fetchUnfilteredCount(t *testing.T, rs referenceSystem, entitySet string) in func newIntegrationClient(rs referenceSystem) *ODataClientImpl { return &ODataClientImpl{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - baseUrl: rs.baseURL, - odataVersion: rs.version, + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseUrl: rs.baseURL, + odataVersion: rs.version, + urlSpaceEncoding: "%20", + } +} + +func isPortOpen(host, port string) bool { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 2*time.Second) + if err != nil { + return false } + conn.Close() + return true } func TestIntegration_ServiceRoot(t *testing.T) { for _, rs := range referenceSystems { rs := rs t.Run(rs.name, func(t *testing.T) { + if rs.requiresLocalServer && !isPortOpen("localhost", "4004") { + t.Skip("local test server not running — start with: cd test-server && pnpm start") + } client := newIntegrationClient(rs) resp, err := client.GetServiceRoot(context.Background()) require.NoError(t, err) @@ -350,6 +183,9 @@ func TestIntegration_Metadata(t *testing.T) { for _, rs := range referenceSystems { rs := rs t.Run(rs.name, func(t *testing.T) { + if rs.requiresLocalServer && !isPortOpen("localhost", "4004") { + t.Skip("local test server not running — start with: cd test-server && pnpm start") + } client := newIntegrationClient(rs) resp, err := client.GetMetadata(context.Background()) require.NoError(t, err) @@ -378,6 +214,9 @@ func TestIntegration_QueryWithFilter(t *testing.T) { for _, rs := range referenceSystems { rs := rs t.Run(rs.name, func(t *testing.T) { + if rs.requiresLocalServer && !isPortOpen("localhost", "4004") { + t.Skip("local test server not running — start with: cd test-server && pnpm start") + } client := newIntegrationClient(rs) types := fetchPropertyTypes(t, client) diff --git a/test-server/db/data-model.cds b/test-server/db/data-model.cds index 5265a03f..51d9bc9f 100644 --- a/test-server/db/data-model.cds +++ b/test-server/db/data-model.cds @@ -1,15 +1,34 @@ namespace test; entity Temperatures { - key id : UUID; - time : DateTime; - epoch : Int64; - value1 : Double; - value2 : Double; - value3 : Double; + key id : UUID; + time : DateTime; + sampledAt : Timestamp; + measurementDate : Date; + epoch : Int64; + sensorId : Integer; + qualityCode : Int16; + value1 : Double; + value2 : Double; + value3 : Double; + pressure : Decimal(7, 2); + isOutdoor : Boolean; + unit : String; } entity Rooms { key id : UUID; name: String } +entity TestPrimitives { + key guid : UUID; + dateTimeOffset : DateTime; + date : Date; + int64 : Int64; + int32 : Integer; + int16 : Int16; + decimal : Decimal(10, 3); + double : Double; + boolean : Boolean; + string : String; +} diff --git a/test-server/generateTestdata.ts b/test-server/generateTestdata.ts index 6c7154c4..6d19cd70 100644 --- a/test-server/generateTestdata.ts +++ b/test-server/generateTestdata.ts @@ -1,5 +1,5 @@ const fs = require('fs'); -const {GenerateTemperatures, GenerateRooms} = require("./mock/Testdata"); +const {GenerateTemperatures, GenerateRooms, GenerateTestPrimitives} = require("./mock/Testdata"); function writeToCSV(name: string, dataGenerator: () => any[]) { const dataArray = dataGenerator(); @@ -10,3 +10,4 @@ function writeToCSV(name: string, dataGenerator: () => any[]) { writeToCSV('Temperatures', GenerateTemperatures); writeToCSV('Rooms', GenerateRooms); +writeToCSV('TestPrimitives', GenerateTestPrimitives); diff --git a/test-server/mock/Testdata.ts b/test-server/mock/Testdata.ts index d7433da1..b0246251 100644 --- a/test-server/mock/Testdata.ts +++ b/test-server/mock/Testdata.ts @@ -1,5 +1,31 @@ import {v4 as uuidv4} from "uuid"; +export const PRIMITIVES_START = '2024-01-01T00:00:00.000Z'; + +export function GenerateTestPrimitives() { + const count = 100; + const startMs = new Date(PRIMITIVES_START).getTime(); + const values = []; + for (let i = 0; i < count; i++) { + const dt = new Date(startMs + i * 60_000); + values.push({ + guid: uuidv4(), + dateTimeOffset: dt.toISOString(), + date: dt.toISOString().substring(0, 10), + int64: i * 1_000_000_000, + int32: i, + int16: i, + decimal: (i * 0.5).toFixed(3), + double: Math.sin(i * Math.PI / 50), + boolean: i % 2 === 0, + string: `item-${String(i).padStart(3, '0')}`, + }); + } + return values; +} + +const UNITS = ['C', 'F', 'K']; + export function GenerateTemperatures() { const count = 1000; @@ -9,13 +35,24 @@ export function GenerateTemperatures() { let epochMs = startTime + (i * 60 * 1000) + Math.floor(Math.random() * 10000); let time = (new Date(epochMs)).toISOString(); + let microseconds = String(Math.floor(Math.random() * 1000)).padStart(3, '0'); + let sampledAt = time.replace(/\.(\d{3})Z$/, `.$1${microseconds}Z`); + let measurementDate = time.substring(0, 10); + let sensorId = (i % 5) + 1; values.push({ id: uuidv4(), time: time, + sampledAt: sampledAt, + measurementDate: measurementDate, epoch: epochMs, + sensorId: sensorId, + qualityCode: Math.random() < 0.9 ? 0 : (Math.random() < 0.5 ? 1 : 2), value1: Math.sin(i) + Math.random(), value2: Math.cos(i) + Math.random(), - value3: Math.log2(i) + Math.random() + value3: Math.log2(i + 1) + Math.random(), + pressure: Math.round((1013.25 + (Math.random() - 0.5) * 20) * 100) / 100, + isOutdoor: sensorId <= 3, + unit: UNITS[i % UNITS.length] }); } return values; diff --git a/test-server/mock/model/MockModel.ts b/test-server/mock/model/MockModel.ts index d36af48a..b4e398b0 100644 --- a/test-server/mock/model/MockModel.ts +++ b/test-server/mock/model/MockModel.ts @@ -7,12 +7,19 @@ export const MockModel = { name: "Temperature", key: { propertyRef: { name: "id" } }, properties: [ - { name: "id", type: "Edm.Guid", nullable: "false" }, - { name: "time", type: "Edm.DateTimeOffset", nullable: "false" }, - { name: "epoch", type: "Edm.Int64", nullable: "false" }, - { name: "value1", type: "Edm.Double", nullable: "false" }, - { name: "value2", type: "Edm.Double", nullable: "false" }, - { name: "value3", type: "Edm.Double", nullable: "false" } + { name: "id", type: "Edm.Guid", nullable: "false" }, + { name: "time", type: "Edm.DateTimeOffset", nullable: "false" }, + { name: "sampledAt", type: "Edm.DateTimeOffset", nullable: "true" }, + { name: "measurementDate", type: "Edm.Date", nullable: "true" }, + { name: "epoch", type: "Edm.Int64", nullable: "false" }, + { name: "sensorId", type: "Edm.Int32", nullable: "true" }, + { name: "qualityCode", type: "Edm.Int16", nullable: "true" }, + { name: "value1", type: "Edm.Double", nullable: "false" }, + { name: "value2", type: "Edm.Double", nullable: "false" }, + { name: "value3", type: "Edm.Double", nullable: "false" }, + { name: "pressure", type: "Edm.Decimal", nullable: "true" }, + { name: "isOutdoor", type: "Edm.Boolean", nullable: "true" }, + { name: "unit", type: "Edm.String", nullable: "true" } ] } ], diff --git a/test-server/srv/test-service.cds b/test-server/srv/test-service.cds index bdf7cd0a..2c105c11 100644 --- a/test-server/srv/test-service.cds +++ b/test-server/srv/test-service.cds @@ -3,4 +3,5 @@ using test from '../db/data-model'; service TestService { entity Temperatures @readonly as projection on test.Temperatures; entity Rooms @readonly as projection on test.Rooms; + entity TestPrimitives @readonly as projection on test.TestPrimitives; }